From 5d9d214bd636cadc5f93ed58a2ed974734e0ffb1 Mon Sep 17 00:00:00 2001 From: busya Date: Fri, 5 Jun 2026 12:07:55 +0300 Subject: [PATCH] 034: fix double JSON serialization in AsyncAPIClient.request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/core/utils/async_network.py | 34 +++++++++++-------- .../src/plugins/translate/preview_executor.py | 15 +++++--- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/backend/src/core/utils/async_network.py b/backend/src/core/utils/async_network.py index aa0dc3bc..fe3c3363 100644 --- a/backend/src/core/utils/async_network.py +++ b/backend/src/core/utils/async_network.py @@ -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() diff --git a/backend/src/plugins/translate/preview_executor.py b/backend/src/plugins/translate/preview_executor.py index 358e38e7..00665418 100644 --- a/backend/src/plugins/translate/preview_executor.py +++ b/backend/src/plugins/translate/preview_executor.py @@ -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)