034: fix double JSON serialization in AsyncAPIClient.request

Root cause: AsyncAPIClient.request() passes its  parameter directly
to httpx.AsyncClient.request(json=data). When callers pass a pre-serialized
JSON string (data=json.dumps(dict)), httpx re-encodes it via json.dumps(),
resulting in a double-encoded JSON string body instead of a JSON object.

This caused ALL POST/PUT requests with string data to fail — Superset received
a JSON string instead of a JSON object, returning GENERIC_BACKEND_ERROR
('dictionary update sequence element #0 has length 1; 2 is required').

Fix: if data is a string, pass it via httpx  parameter (raw body);
if it's a dict/list, pass via  for automatic encoding.

Affected callers (6 files) now correctly send JSON objects:
  - preview_executor.py: chart data requests
  - superset_executor.py
  - _run_source.py
  - _datasets.py: update_dataset
  - _datasets_preview.py: compile_dataset_preview
  - _dashboards_write.py

Also simplified preview_executor.fetch_sample_rows back to single-strategy
(chart data API only) since the root cause is now fixed.
This commit is contained in:
2026-06-05 12:07:55 +03:00
parent 72fcf698af
commit 5d9d214bd6
2 changed files with 30 additions and 19 deletions

View File

@@ -201,15 +201,23 @@ class AsyncAPIClient:
auth_headers = await self.get_headers()
req_headers.update(auth_headers)
response = await self._client.request(
method=method,
url=url,
params=params,
json=data,
headers=req_headers,
timeout=httpx.Timeout(timeout or self.request_settings.get("timeout", self.DEFAULT_TIMEOUT)),
follow_redirects=allow_redirects,
)
# Support both pre-serialized JSON strings (legacy convention)
# and Python dicts/list. Strings are sent as raw body via `content`
# to avoid double-encoding by httpx's `json` parameter.
request_kwargs: dict[str, Any] = {
"method": method,
"url": url,
"params": params,
"headers": req_headers,
"timeout": httpx.Timeout(timeout or self.request_settings.get("timeout", self.DEFAULT_TIMEOUT)),
"follow_redirects": allow_redirects,
}
if isinstance(data, str):
request_kwargs["content"] = data.encode("utf-8")
elif data is not None:
request_kwargs["json"] = data
response = await self._client.request(**request_kwargs)
if response.status_code == 401:
if method.upper() in ("GET", "HEAD", "OPTIONS"):
@@ -217,12 +225,8 @@ class AsyncAPIClient:
self._authenticated = False
auth_headers = await self.get_headers()
req_headers.update(auth_headers)
response = await self._client.request(
method=method, url=url, params=params, json=data,
headers=req_headers,
timeout=httpx.Timeout(timeout or self.request_settings.get("timeout", self.DEFAULT_TIMEOUT)),
follow_redirects=allow_redirects,
)
retry_kwargs = {**request_kwargs, "headers": req_headers}
response = await self._client.request(**retry_kwargs)
else:
raise AuthenticationError()

View File

@@ -37,7 +37,7 @@ class PreviewExecutor:
# region fetch_sample_rows [TYPE Function]
# @PURPOSE: Fetch sample rows from Superset dataset for preview.
# @SIDE_EFFECT Calls Superset chart data endpoint.
# @SIDE_EFFECT Calls Superset chart data API.
async def fetch_sample_rows(
self,
job: TranslationJob,
@@ -60,13 +60,20 @@ class PreviewExecutor:
dataset_id=int(job.source_datasource_id), dataset_record=dataset_detail,
template_params={}, effective_filters=[],
)
# Build column list from dataset schema for explicit column projection
column_names = [
col["name"] for col in dataset_detail.get("columns", [])
if col.get("name") and col.get("is_active", True)
]
queries = query_context.get("queries", [])
if queries:
queries[0]["row_limit"] = sample_size
queries[0].pop("result_type", None)
queries[0].pop("columns", None)
queries[0]["columns"] = column_names or []
queries[0]["metrics"] = []
query_context["result_type"] = "samples"
queries[0].pop("result_type", None)
query_context["result_type"] = "query"
form_data = query_context.get("form_data", {})
form_data.pop("query_mode", None)