Phase 28: P3 — Pydantic Settings, dependency-injector IoC, tenacity retry
- config.py: dataclasses → pydantic-settings BaseSettings (flat AppConfig, env vars auto-loaded from .env, type-safe validation) - api_client.py: HTTPAPIClient takes AppConfig directly (APIConfig removed); tenacity retry on 5 methods (reset/ingest/list/delete/feedback) — retries on 5xx + TransportError, 3 attempts, exponential backoff 1-8s - container.py: manual DI → dependency_injector DeclarativeContainer with providers.Singleton; Container() needs no args - app.py: container.X → container.X() calls, remove AppConfig import - requirements.txt: add pydantic-settings, tenacity, dependency-injector Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+26
-5
@@ -5,8 +5,23 @@ import urllib.parse
|
||||
from typing import AsyncIterator, Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||
|
||||
from config import APIConfig
|
||||
from config import AppConfig
|
||||
|
||||
|
||||
def _is_transient(exc: Exception) -> bool:
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return exc.response.status_code >= 500
|
||||
return isinstance(exc, httpx.TransportError)
|
||||
|
||||
|
||||
_retry = retry(
|
||||
retry=retry_if_exception(_is_transient),
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=8),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -29,11 +44,12 @@ class APIClientProtocol(Protocol):
|
||||
|
||||
|
||||
class HTTPAPIClient:
|
||||
def __init__(self, config: APIConfig):
|
||||
self._url = config.url.rstrip("/")
|
||||
self._timeout = config.timeout
|
||||
def __init__(self, config: AppConfig):
|
||||
self._url = config.youlbot_api_url.rstrip("/")
|
||||
self._timeout = config.youlbot_api_timeout
|
||||
self._client = httpx.AsyncClient(
|
||||
headers={"Authorization": f"Bearer {config.token}"} if config.token else {},
|
||||
headers={"Authorization": f"Bearer {config.youlbot_api_token}"}
|
||||
if config.youlbot_api_token else {},
|
||||
)
|
||||
|
||||
async def chat(
|
||||
@@ -63,6 +79,7 @@ class HTTPAPIClient:
|
||||
return
|
||||
yield payload, None
|
||||
|
||||
@_retry
|
||||
async def reset(self, user_id: str = "default") -> None:
|
||||
r = await self._client.post(
|
||||
f"{self._url}/reset",
|
||||
@@ -71,6 +88,7 @@ class HTTPAPIClient:
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
@_retry
|
||||
async def ingest(self, file_path: str) -> dict:
|
||||
with open(file_path, "rb") as f:
|
||||
filename = os.path.basename(file_path)
|
||||
@@ -82,16 +100,19 @@ class HTTPAPIClient:
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@_retry
|
||||
async def list_documents(self) -> list[str]:
|
||||
r = await self._client.get(f"{self._url}/documents", timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json().get("documents", [])
|
||||
|
||||
@_retry
|
||||
async def delete_document(self, source: str) -> None:
|
||||
encoded = urllib.parse.quote(source, safe="")
|
||||
r = await self._client.delete(f"{self._url}/documents/{encoded}", timeout=30)
|
||||
r.raise_for_status()
|
||||
|
||||
@_retry
|
||||
async def save_feedback(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
Reference in New Issue
Block a user