| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| """ |
| HTTP client for Kiro API with retry logic support. |
| |
| Handles: |
| - 403: automatic token refresh and retry |
| - 429: exponential backoff |
| - 5xx: exponential backoff |
| - Timeouts: exponential backoff |
| |
| Supports both per-request clients and shared application-level client |
| with connection pooling for better resource management. |
| """ |
|
|
| import asyncio |
| from typing import Optional |
|
|
| import httpx |
| from fastapi import HTTPException |
| from loguru import logger |
|
|
| from kiro.config import MAX_RETRIES, BASE_RETRY_DELAY, FIRST_TOKEN_MAX_RETRIES, STREAMING_READ_TIMEOUT |
| from kiro.auth import KiroAuthManager |
| from kiro.utils import get_kiro_headers |
| from kiro.network_errors import classify_network_error, get_short_error_message, NetworkErrorInfo |
|
|
|
|
| class KiroHttpClient: |
| """ |
| HTTP client for Kiro API with retry logic support. |
| |
| Automatically handles errors and retries requests: |
| - 403: refreshes token and retries |
| - 429: waits with exponential backoff |
| - 5xx: waits with exponential backoff |
| - Timeouts: waits with exponential backoff |
| |
| Supports two modes of operation: |
| 1. Per-request client: Creates and owns its own httpx.AsyncClient |
| 2. Shared client: Uses an application-level shared client (recommended) |
| |
| Using a shared client reduces memory usage and enables connection pooling, |
| which is especially important for handling concurrent requests. |
| |
| Attributes: |
| auth_manager: Authentication manager for obtaining tokens |
| client: httpx HTTP client (owned or shared) |
| |
| Example: |
| >>> # Per-request client (legacy mode) |
| >>> client = KiroHttpClient(auth_manager) |
| >>> response = await client.request_with_retry(...) |
| |
| >>> # Shared client (recommended) |
| >>> shared = httpx.AsyncClient(limits=httpx.Limits(...)) |
| >>> client = KiroHttpClient(auth_manager, shared_client=shared) |
| >>> response = await client.request_with_retry(...) |
| """ |
| |
| def __init__( |
| self, |
| auth_manager: KiroAuthManager, |
| shared_client: Optional[httpx.AsyncClient] = None |
| ): |
| """ |
| Initializes the HTTP client. |
| |
| Args: |
| auth_manager: Authentication manager |
| shared_client: Optional shared httpx.AsyncClient for connection pooling. |
| If provided, this client will be used instead of creating |
| a new one. The shared client will NOT be closed by close(). |
| """ |
| self.auth_manager = auth_manager |
| self._shared_client = shared_client |
| self._owns_client = shared_client is None |
| self.client: Optional[httpx.AsyncClient] = shared_client |
| |
| async def _get_client(self, stream: bool = False) -> httpx.AsyncClient: |
| """ |
| Returns or creates an HTTP client with proper timeouts. |
| |
| If a shared client was provided at initialization, it is returned as-is. |
| Otherwise, creates a new client with appropriate timeout configuration. |
| |
| httpx timeouts: |
| - connect: TCP handshake (DNS + TCP SYN/ACK) |
| - read: waiting for data from server between chunks |
| - write: sending data to server |
| - pool: waiting for free connection from pool |
| |
| IMPORTANT: FIRST_TOKEN_TIMEOUT is NOT used here! |
| It is applied in streaming_openai.py via asyncio.wait_for() to control |
| the wait time for the first token from the model (retry business logic). |
| |
| Args: |
| stream: If True, uses STREAMING_READ_TIMEOUT for read (only for new clients) |
| |
| Returns: |
| Active HTTP client |
| """ |
| |
| |
| if self._shared_client is not None: |
| return self._shared_client |
| |
| |
| if self.client is None or self.client.is_closed: |
| if stream: |
| |
| |
| |
| |
| timeout_config = httpx.Timeout( |
| connect=30.0, |
| read=STREAMING_READ_TIMEOUT, |
| write=30.0, |
| pool=30.0 |
| ) |
| logger.debug(f"Creating streaming HTTP client (read_timeout={STREAMING_READ_TIMEOUT}s)") |
| else: |
| |
| timeout_config = httpx.Timeout(timeout=300.0) |
| logger.debug("Creating non-streaming HTTP client (timeout=300s)") |
| |
| self.client = httpx.AsyncClient(timeout=timeout_config, follow_redirects=True) |
| return self.client |
| |
| async def close(self) -> None: |
| """ |
| Closes the HTTP client if this instance owns it. |
| |
| If using a shared client, this method does nothing - the shared client |
| should be closed by the application lifecycle manager. |
| |
| Uses graceful exception handling to prevent errors during cleanup |
| from masking the original exception in finally blocks. |
| """ |
| |
| if not self._owns_client: |
| return |
| |
| if self.client and not self.client.is_closed: |
| try: |
| await self.client.aclose() |
| except Exception as e: |
| |
| |
| logger.warning(f"Error closing HTTP client: {e}") |
| |
| async def request_with_retry( |
| self, |
| method: str, |
| url: str, |
| json_data: dict, |
| stream: bool = False |
| ) -> httpx.Response: |
| """ |
| Executes an HTTP request with retry logic. |
| |
| Automatically handles various error types: |
| - 403: refreshes token via auth_manager.force_refresh() and retries |
| - 429: waits with exponential backoff (1s, 2s, 4s) |
| - 5xx: waits with exponential backoff |
| - Timeouts: waits with exponential backoff |
| |
| For streaming, STREAMING_READ_TIMEOUT is used for waiting between chunks. |
| First token timeout is controlled separately in streaming_openai.py via asyncio.wait_for(). |
| |
| Args: |
| method: HTTP method (GET, POST, etc.) |
| url: Request URL |
| json_data: Request body (JSON) |
| stream: Use streaming (default False) |
| |
| Returns: |
| httpx.Response with successful response |
| |
| Raises: |
| HTTPException: On failure after all attempts (502/504) |
| """ |
| |
| |
| max_retries = FIRST_TOKEN_MAX_RETRIES if stream else MAX_RETRIES |
| |
| client = await self._get_client(stream=stream) |
| last_error = None |
| last_error_info: Optional[NetworkErrorInfo] = None |
| |
| for attempt in range(max_retries): |
| try: |
| |
| token = await self.auth_manager.get_access_token() |
| headers = get_kiro_headers(self.auth_manager, token) |
| |
| if stream: |
| |
| headers["Connection"] = "close" |
| req = client.build_request(method, url, json=json_data, headers=headers) |
| logger.debug("Sending request to Kiro API...") |
| response = await client.send(req, stream=True) |
| else: |
| logger.debug("Sending request to Kiro API...") |
| response = await client.request(method, url, json=json_data, headers=headers) |
| |
| |
| if response.status_code == 200: |
| return response |
| |
| |
| if response.status_code == 403: |
| logger.warning(f"Received 403, refreshing token (attempt {attempt + 1}/{MAX_RETRIES})") |
| await self.auth_manager.force_refresh() |
| continue |
| |
| |
| if response.status_code == 429: |
| delay = BASE_RETRY_DELAY * (2 ** attempt) |
| logger.warning(f"Received 429, waiting {delay}s (attempt {attempt + 1}/{MAX_RETRIES})") |
| await asyncio.sleep(delay) |
| continue |
| |
| |
| if 500 <= response.status_code < 600: |
| delay = BASE_RETRY_DELAY * (2 ** attempt) |
| logger.warning(f"Received {response.status_code}, waiting {delay}s (attempt {attempt + 1}/{MAX_RETRIES})") |
| await asyncio.sleep(delay) |
| continue |
| |
| |
| return response |
| |
| except httpx.TimeoutException as e: |
| last_error = e |
| |
| |
| error_info = classify_network_error(e) |
| last_error_info = error_info |
| |
| |
| short_msg = get_short_error_message(error_info) |
| |
| if error_info.is_retryable and attempt < max_retries - 1: |
| delay = BASE_RETRY_DELAY * (2 ** attempt) |
| logger.warning(f"{short_msg} - waiting {delay}s (attempt {attempt + 1}/{max_retries})") |
| await asyncio.sleep(delay) |
| else: |
| logger.error(f"{short_msg} - no more retries (attempt {attempt + 1}/{max_retries})") |
| if not error_info.is_retryable: |
| break |
| |
| except httpx.RequestError as e: |
| last_error = e |
| |
| |
| error_info = classify_network_error(e) |
| last_error_info = error_info |
| |
| |
| short_msg = get_short_error_message(error_info) |
| |
| if error_info.is_retryable and attempt < max_retries - 1: |
| delay = BASE_RETRY_DELAY * (2 ** attempt) |
| logger.warning(f"{short_msg} - waiting {delay}s (attempt {attempt + 1}/{max_retries})") |
| await asyncio.sleep(delay) |
| else: |
| logger.error(f"{short_msg} - no more retries (attempt {attempt + 1}/{max_retries})") |
| if not error_info.is_retryable: |
| break |
| |
| |
| if last_error_info: |
| |
| error_message = last_error_info.user_message |
| |
| |
| if last_error_info.troubleshooting_steps: |
| error_message += "\n\nTroubleshooting:\n" |
| for i, step in enumerate(last_error_info.troubleshooting_steps, 1): |
| error_message += f"{i}. {step}\n" |
| |
| |
| error_message += f"\nTechnical details: {last_error_info.technical_details}" |
| |
| raise HTTPException( |
| status_code=last_error_info.suggested_http_code, |
| detail=error_message.strip() |
| ) |
| else: |
| |
| if stream: |
| raise HTTPException( |
| status_code=504, |
| detail=f"Streaming failed after {max_retries} attempts. Unknown error." |
| ) |
| else: |
| raise HTTPException( |
| status_code=502, |
| detail=f"Request failed after {max_retries} attempts. Unknown error." |
| ) |
| |
| async def __aenter__(self) -> "KiroHttpClient": |
| """Async context manager support.""" |
| return self |
| |
| async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: |
| """Closes the client when exiting context.""" |
| await self.close() |