diff --git a/backend/src/core/utils/async_network.py b/backend/src/core/utils/async_network.py index 87f899fa..7f5d6bec 100644 --- a/backend/src/core/utils/async_network.py +++ b/backend/src/core/utils/async_network.py @@ -8,6 +8,11 @@ # @DATA_CONTRACT Input[config: Dict[str, Any]] -> Output[authenticated async Superset HTTP interactions] # @RELATION DEPENDS_ON -> [SupersetAuthCache] # @INVARIANT Async client reuses cached auth tokens per environment credentials and invalidates on 401. +# @RATIONALE Async migration of Superset network layer. Uses httpx.AsyncClient with session-level +# cookie persistence for CSRF. Shared auth cache reduces per-request login overhead from O(n) to O(1). +# String data passed as httpx content (not json) to avoid double-encoding of pre-serialized JSON. +# @REJECTED requests.Session — blocks async event loop. Per-request AsyncAPIClient — loses connection +# pooling and CSRF session cookies, causing 'CSRF tokens do not match' on SQL Lab execute. import asyncio import io import json @@ -36,6 +41,11 @@ from .network import ( # @RELATION CALLS -> [SupersetAuthCache.get] # @RELATION CALLS -> [SupersetAuthCache.set] # @INVARIANT semaphore.acquire() before request and semaphore.release() in finally. +# @RATIONALE One class handles auth lifecycle, CSRF token refresh on cache-hit, and per-env +# semaphore for connection backpressure. authenticate() was extended to re-fetch CSRF token +# on cache-hit to set session cookie on new httpx.AsyncClient instances. +# @REJECTED Per-request client with separate httpx.AsyncClient — loses CSRF session cookie, +# causes 'CSRF tokens do not match' on SQL Lab execute. No semaphore — no backpressure. class AsyncAPIClient: DEFAULT_TIMEOUT = 30 _auth_locks: dict[tuple[str, str, bool], asyncio.Lock] = {} @@ -207,6 +217,12 @@ class AsyncAPIClient: # @RELATION CALLS -> [AsyncAPIClient._handle_http_error] # @RELATION CALLS -> [AsyncAPIClient._handle_network_error] # @RELATION CALLS -> [AsyncAPIClient._is_dashboard_endpoint] + # @RATIONALE Supports both pre-serialized JSON strings (legacy callers) and Python dicts. + # Strings sent via httpx content parameter to avoid double encoding by json parameter. + # This was the root cause of GENERIC_BACKEND_ERROR on all POST requests — httpx re-encoded + # pre-serialized JSON, turning the request body into a JSON string instead of a JSON object. + # @REJECTED Passing all data through httpx json= — double-encodes strings. Using only content= + # breaks callers that pass dicts. Always passing dicts — requires refactoring 50+ callers. async def request( self, method: str, @@ -403,6 +419,10 @@ class AsyncAPIClient: # @POST Returns parsed JSON API response. # @SIDE_EFFECT Performs multipart HTTP upload via httpx.AsyncClient. # @RELATION CALLS -> [AsyncAPIClient.get_headers] + # @RATIONALE Async migration of requests.post to httpx.AsyncClient.post with multipart files. + # Uses httpx files= parameter for multipart upload with file_name and Content-Type per file. + # @REJECTED requests.post with files= — blocks async event loop. Direct file read -> httpx content + # — loses multipart boundary handling. async def upload_file( self, endpoint: str, diff --git a/backend/src/plugins/translate/_llm_async_http.py b/backend/src/plugins/translate/_llm_async_http.py index 6389acb7..70d43a7e 100644 --- a/backend/src/plugins/translate/_llm_async_http.py +++ b/backend/src/plugins/translate/_llm_async_http.py @@ -7,8 +7,11 @@ # @POST Returns (response text, finish_reason) tuple. # @SIDE_EFFECT Async HTTP POST to LLM API with optional retry on 429. # @RATIONALE Async migration of _llm_http.py to use httpx.AsyncClient instead of sync requests. -# Uses asyncio.sleep for 429 backoff instead of time.sleep. +# Uses asyncio.sleep for 429 backoff instead of time.sleep. Module-level httpx client singleton +# for connection reuse. response.ok -> response.is_success for httpx.Response compatibility. # @REJECTED Keeping sync requests.post — would block async event loop during LLM calls. +# Per-request httpx.AsyncClient — loses connection pooling. response.ok — httpx.Response has no +# .ok attribute (only is_success), causes 'Response' object has no attribute 'ok'. import asyncio import os