```
# network_manager.py
import asyncio
from contextlib import asynccontextmanager
from logging import getLogger
from typing import Callable, Awaitable, Optional, AsyncIterator, Any

import httpx
from aiolimiter import AsyncLimiter
from httpx import AsyncClient

from configs.misc_config import HEADERS

ProgressCB = Callable[[float], Awaitable[None] | None]

logger_local = getLogger(__name__)


class NetworkManager:
    # ---------- 初期化 ----------

    def __init__(
            self,
            rate_limiter: AsyncLimiter | None = None,
            byte_limiter: AsyncLimiter | None = None,
    ) -> None:
        self.rate_limiter = rate_limiter or AsyncLimiter(max_rate=4, time_period=1)
        self.byte_limiter = byte_limiter or AsyncLimiter(max_rate=3 * 1024 * 1024, time_period=1)

        self._client: Optional[AsyncClient] = None  # 実体
        self._ready = asyncio.Event()  # 生成完了通知
        self._creating_lock = asyncio.Lock()  # 生成は 1 並列だけ

        # すぐに生成を開始（でも待たない）
        asyncio.create_task(self._spawn_client())

        logger_local.info("NetworkManager created (client spawning in background)")

    # ---------- AsyncClient 生成 ----------

    @staticmethod
    def _make_default_client_sync() -> AsyncClient:
        """ブロッキング作業なので sync 関数で定義"""
        return httpx.AsyncClient(http2=True, timeout=60.0, headers=HEADERS)

    async def _spawn_client(self) -> None:
        """別タスクで呼ばれる。完了したら Event を set。"""
        async with self._creating_lock:
            if self._client is not None:  # すでに誰かが作った
                return
            self._client = await asyncio.to_thread(
                self._make_default_client_sync
            )
            self._ready.set()
            logger_local.info("AsyncClient ready")

    async def _ensure_client(self) -> AsyncClient:
        """呼び出し側が必ず最初に叩く"""
        if not self._ready.is_set():
            await self._ready.wait()
        return self._client  # type: ignore[return-value]

    # ---------- 手動クローズ ----------

    async def close(self) -> None:
        if self._client is not None:
            await self._client.aclose()
            logger_local.info("NetworkManager closed")
            self._client = None
        # 以後呼ばれたら再生成できるよう初期化をリセット
        self._ready.clear()
        asyncio.get_running_loop().create_task(self._spawn_client())

    # ---------- 低レベル GET API ----------

    @asynccontextmanager
    async def get(
            self,
            url: str,
            params: Optional[dict[str, Any]] = None,
            rate_limiter: Optional[AsyncLimiter] = None,
            **kwargs,
    ) -> AsyncIterator[httpx.Response]:
        """
        レート制限付き GET

        kwargs に stream=True を渡すと .stream() を使用
        """
        client = await self._ensure_client()  # ← ここで待機する可能性あり
        _rate_limiter = rate_limiter or self.rate_limiter
        stream = kwargs.pop("stream", False)

        async with _rate_limiter:
            if stream:
                resp_cm = client.stream("GET", url, params=params,
                                        follow_redirects=True, **kwargs)
                response = await resp_cm.__aenter__()
            else:
                response = await client.get(url, params=params,
                                            follow_redirects=True, **kwargs)

        response.raise_for_status()
        try:
            yield response
        finally:
            await response.aclose()
```

```
# download_service.py
import asyncio
import inspect
import os
from pathlib import Path
from typing import Optional

import aiofiles
from aiolimiter import AsyncLimiter

from configs.path_config import CACHE_DIR
from debug.timer import timer
from network_manager import NetworkManager, ProgressCB


class DownloadService:
    def __init__(
            self,
            net: NetworkManager,
            rate_limiter: Optional[AsyncLimiter] = None,
            byte_limiter: Optional[AsyncLimiter] = None,
    ):
        self.net = net
        self.rate_limiter = rate_limiter or net.rate_limiter
        self.byte_limiter = byte_limiter or net.byte_limiter

    async def fetch(self, url: str, **kwargs):
        async with self.net.get(url, rate_limiter=self.rate_limiter, stream=True, **kwargs) as resp:
            return await resp.aread()  # 読み切って bytes を返す例

    def fetch_sync(self, url: str):
        pass

    async def save(
            self,
            url: str,
            save_dir: Path,
            overwrite: bool = False,
            return_cache: bool = True,
            *,
            on_progress: ProgressCB | None = None,
            chunk_size: int = 64 * 1024,
    ) -> Path:
        save_dir.mkdir(parents=True, exist_ok=True)

        filename = Path(url).name or "download.bin"
        dest = save_dir / filename
        tmp = save_dir / f"{filename}.tmp"

        # 既存ファイルの扱い
        if dest.exists() and not overwrite:
            if return_cache:
                return dest
            raise FileExistsError(f"File already exists: {dest}")

        try:
            async with self.net.get(url, stream=True) as resp:
                total = int(resp.headers.get("content-length", 0))
                downloaded = 0

                # 途中で落ちた残骸を消してから開始
                tmp.unlink(missing_ok=True)

                async with aiofiles.open(tmp, "wb") as fp:
                    async for chunk in resp.aiter_bytes(chunk_size):
                        await self.byte_limiter.acquire(len(chunk))
                        await fp.write(chunk)
                        downloaded += len(chunk)

                        if on_progress and total:
                            if inspect.isawaitable(r := on_progress(downloaded / total)):
                                await r

            # 100% 通知
            if on_progress and total:
                if inspect.isawaitable(r := on_progress(1.0)):
                    await r

            os.replace(tmp, dest)  # 完了 → atomic rename
            return dest

        except BaseException:  # Ctrl-C, CancelledError など含めて全捕捉
            tmp.unlink(missing_ok=True)  # 失敗や割り込み時は残骸を掃除
            raise  # もとの例外を再送出


async def main():
    net = NetworkManager()
    d_serv = DownloadService(net=net)
    with timer(prefix="download: "):
        result = await d_serv.save(
            url=r"https://www3.nhk.or.jp/news/html/20250618/K10014838111_2506181222_0618123632_01_02.jpg",
            save_dir=CACHE_DIR, overwrite=True)
    print(f"result: {result}")
    await net.close()


if __name__ == "__main__":
    asyncio.run(main())
```

クリーンアーキテクチャに則って再設計して。リングごとにフォルダを分け、更に抽象と実装で分けて。