038: add @RATIONALE/@REJECTED contracts to async C4/C5 modules

5 contracts updated:
  - AsyncNetworkModule (C5): async migration rationale, per-client CSRF cookie rejection
  - AsyncAPIClient (C4): auth lifecycle, cache-hit CSRF refresh
  - AsyncAPIClient.request (C4): string vs dict handling, double-encoding root cause
  - AsyncAPIClient.upload_file (C4): multipart async upload
  - LLMAsyncHttpClient (C4): response.ok -> is_success, module-level httpx singleton
This commit is contained in:
2026-06-05 17:02:30 +03:00
parent fbe0ba122c
commit f731a53c7d
2 changed files with 24 additions and 1 deletions

View File

@@ -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,

View File

@@ -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