Skip to content

Commit 5d7af3b

Browse files
Fix JSONDecodeError in result_batch._load() when fetching large result sets (#2802)
Co-authored-by: Filip Pawłowski <filip.pawlowski@snowflake.com>
1 parent 469f300 commit 5d7af3b

4 files changed

Lines changed: 294 additions & 2 deletions

File tree

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1616
- Fixed a bug where Azure GET commands would incorrectly set the file status to UPLOADED instead of preserving the DOWNLOADED status during metadata retrieval.
1717
- Renamed the environment variable for skipping config file permission warnings from `SF_SKIP_WARNING_FOR_READ_PERMISSIONS_ON_CONFIG_FILE` to `SF_SKIP_TOKEN_FILE_PERMISSIONS_VERIFICATION`. The old variable is still supported but emits a deprecation warning.
1818
- Fixed `unsafe_skip_file_permissions_check` flag not being respected when reading `connections.toml`.
19+
- Fixed JSONDecodeError in result_batch._load() when fetching large result sets
1920

2021
- v4.3.0(February 12,2026)
2122
- Ensured proper list conversion - the converter runs to_snowflake on all list elements.

src/snowflake/connector/result_batch.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import abc
4+
import gzip
45
import json
56
import time
67
from base64 import b64decode
@@ -52,6 +53,25 @@
5253
SSE_C_KEY = "x-amz-server-side-encryption-customer-key"
5354
SSE_C_AES = "AES256"
5455

56+
_GZIP_MAGIC = b"\x1f\x8b"
57+
58+
59+
def _ensure_decompressed(response: Response) -> None:
60+
"""Decompress the response body if HTTP-level gzip decompression was skipped.
61+
62+
Cloud storage (S3/GCS/Azure) may serve result-set chunks as raw gzip blobs
63+
without a ``Content-Encoding: gzip`` header, in which case urllib3's
64+
transparent decompression never activates. Detect this by inspecting the
65+
cached *response.content* for the gzip magic number and decompress in-place.
66+
"""
67+
content = response.content
68+
if isinstance(content, bytes) and content[:2] == _GZIP_MAGIC:
69+
logger.debug(
70+
"Response body starts with gzip magic number but was not "
71+
"decompressed by the HTTP stack; decompressing explicitly."
72+
)
73+
response._content = gzip.decompress(content)
74+
5575

5676
def _create_nanoarrow_iterator(
5777
data: bytes,
@@ -418,6 +438,8 @@ def _download(
418438
self._metrics[DownloadMetrics.download.value] = (
419439
download_metric.get_timing_millis()
420440
)
441+
442+
_ensure_decompressed(response)
421443
return response
422444

423445
@abc.abstractmethod

test/integ/test_large_result_set.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import logging
5+
import threading
56
from unittest.mock import Mock
67

78
import pytest
@@ -221,3 +222,80 @@ def spy_download(self, connection=None, **kwargs): # type: ignore[no-self-use]
221222
# Every ResultBatch download reused the same HTTP configuration values
222223
for cfg in download_cfgs:
223224
assert cfg == original_cfg
225+
226+
227+
# ---------------------------------------------------------------------------
228+
# Multichunk JSON gzip decompression regression test
229+
#
230+
# Verifies that the _ensure_decompressed safety net in _download() correctly
231+
# handles the case where the HTTP transport does not decompress gzip chunk
232+
# responses (missing Content-Encoding: gzip header from cloud storage).
233+
#
234+
# The standard Snowflake download path *does* decompress correctly because
235+
# the server sets Content-Encoding: gzip and urllib3 honours it. The client
236+
# failure occurs under environmental conditions we cannot reproduce in CI
237+
# (e.g. a corporate proxy or CDN stripping Content-Encoding).
238+
#
239+
# To prove the safety net works we instrument _download to strip the
240+
# Content-Encoding header from the *real* server response before urllib3
241+
# consumes it, forcing urllib3 to skip decompression.
242+
# ---------------------------------------------------------------------------
243+
244+
245+
def _strip_content_encoding(response):
246+
"""Remove Content-Encoding from a urllib3 raw response so decompression is skipped.
247+
248+
Must be called *before* response.content is accessed (i.e. before the
249+
requests library iterates the body), because once the body is consumed
250+
the encoding has already been (or not been) applied.
251+
"""
252+
if hasattr(response, "raw") and response.raw is not None:
253+
response.raw.headers.pop("Content-Encoding", None)
254+
response.raw.headers.pop("content-encoding", None)
255+
response.raw._decoder = None
256+
response.raw.decode_content = False
257+
258+
259+
@pytest.mark.aws
260+
@pytest.mark.skipolddriver
261+
def test_multichunk_json_gzip_decompression_with_fix(
262+
conn_cnx, db_parameters, ingest_data
263+
):
264+
"""With _ensure_decompressed active, multichunk JSON downloads succeed
265+
even when the HTTP layer skips gzip decompression."""
266+
from snowflake.connector.vendored.requests.adapters import HTTPAdapter
267+
268+
original_send = HTTPAdapter.send
269+
chunks_intercepted = 0
270+
chunks_intercepted_lock = threading.Lock()
271+
272+
def send_strip_encoding(self, request, *args, **kwargs):
273+
"""Intercept the adapter to strip Content-Encoding after the real
274+
HTTP round-trip but before requests reads the body."""
275+
nonlocal chunks_intercepted
276+
response = original_send(self, request, *args, **kwargs)
277+
if response.status_code == 200:
278+
_strip_content_encoding(response)
279+
with chunks_intercepted_lock:
280+
chunks_intercepted += 1
281+
return response
282+
283+
table_name = db_parameters["name"]
284+
with conn_cnx(
285+
session_parameters={"python_connector_query_result_format": "JSON"},
286+
) as cnx:
287+
original_adapter_send = HTTPAdapter.send
288+
HTTPAdapter.send = send_strip_encoding
289+
try:
290+
cur = cnx.cursor()
291+
cur.execute(f"select * from {table_name} order by 1")
292+
rows = cur.fetchall()
293+
finally:
294+
HTTPAdapter.send = original_adapter_send
295+
296+
assert (
297+
len(rows) == NUMBER_OF_ROWS
298+
), f"Expected {NUMBER_OF_ROWS} rows, got {len(rows)}"
299+
assert (
300+
chunks_intercepted > 0
301+
), "No HTTP responses were intercepted -- test didn't exercise the fix"

test/unit/test_result_batch.py

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
#!/usr/bin/env python
22
from __future__ import annotations
33

4+
import gzip
5+
import json
46
from collections import namedtuple
7+
from concurrent.futures import ThreadPoolExecutor, as_completed
58
from http import HTTPStatus
69
from test.helpers import create_mock_response
710
from unittest import mock
@@ -40,15 +43,22 @@
4043
try:
4144
from snowflake.connector.compat import TOO_MANY_REQUESTS
4245
from snowflake.connector.errors import TooManyRequests
43-
from snowflake.connector.result_batch import MAX_DOWNLOAD_RETRY, JSONResultBatch
44-
from snowflake.connector.vendored import requests # NOQA
46+
from snowflake.connector.result_batch import (
47+
MAX_DOWNLOAD_RETRY,
48+
JSONResultBatch,
49+
RemoteChunkInfo,
50+
_ensure_decompressed,
51+
)
52+
from snowflake.connector.vendored import requests
4553

4654
SESSION_FROM_REQUEST_MODULE_PATH = (
4755
"snowflake.connector.vendored.requests.sessions.Session"
4856
)
4957
except ImportError:
5058
MAX_DOWNLOAD_RETRY = None
5159
JSONResultBatch = None
60+
RemoteChunkInfo = None
61+
_ensure_decompressed = None
5262
SESSION_FROM_REQUEST_MODULE_PATH = "requests.sessions.Session"
5363
TooManyRequests = None
5464
TOO_MANY_REQUESTS = None
@@ -151,3 +161,184 @@ def test_retries_until_success():
151161
assert res.raw == "success"
152162
# call `get` once for each error and one last time when it succeeds
153163
assert mock_get.call_count == len(error_codes) + 1
164+
165+
166+
# ---------------------------------------------------------------------------
167+
# Gzip decompression fallback tests
168+
#
169+
# These reproduce the JSONDecodeError observed when cloud storage serves
170+
# result-set chunks as raw gzip blobs *without* a Content-Encoding: gzip
171+
# header. urllib3 v2 only triggers transparent decompression when that
172+
# header is present, so the raw \x1f\x8b bytes leak into response.text.
173+
# ---------------------------------------------------------------------------
174+
175+
176+
def _make_gzip_json_rows(*rows):
177+
"""Encode rows as Snowflake-style comma-separated JSON and gzip-compress."""
178+
payload = ",\n".join(json.dumps(row) for row in rows)
179+
return gzip.compress(payload.encode("utf-8"))
180+
181+
182+
def _make_gzip_response(compressed_body: bytes):
183+
"""Build a fake requests.Response whose .content is raw gzip bytes.
184+
185+
This simulates what happens when cloud storage returns gzip data
186+
without setting Content-Encoding: gzip -- the requests/urllib3 stack
187+
skips decompression and .content returns the raw compressed bytes.
188+
"""
189+
resp = requests.Response()
190+
resp.status_code = 200
191+
resp._content = compressed_body
192+
resp.headers["Content-Type"] = "application/json"
193+
return resp
194+
195+
196+
@pytest.mark.skipif(JSONResultBatch is None, reason="vendored requests unavailable")
197+
class TestGzipDecompressionFallback:
198+
"""Verify _ensure_decompressed fixes responses that were not decoded by urllib3."""
199+
200+
def test_ensure_decompressed_unpacks_gzip_content(self):
201+
"""_ensure_decompressed should replace raw gzip bytes with decompressed content."""
202+
rows = [["Alice", 30], ["Bob", 25]]
203+
raw_gz = _make_gzip_json_rows(*rows)
204+
assert raw_gz[:2] == b"\x1f\x8b", "sanity: payload is gzip"
205+
206+
resp = _make_gzip_response(raw_gz)
207+
assert resp.content[:2] == b"\x1f\x8b", "before fix: content is raw gzip"
208+
209+
_ensure_decompressed(resp)
210+
211+
assert resp.content[:2] != b"\x1f\x8b", "after fix: gzip magic gone"
212+
recovered = json.loads("[" + resp.content.decode("utf-8") + "]")
213+
assert recovered == rows
214+
215+
def test_ensure_decompressed_leaves_plain_json_alone(self):
216+
"""_ensure_decompressed should be a no-op for already-decoded responses."""
217+
plain = b'["Alice", 30],\n["Bob", 25]'
218+
resp = requests.Response()
219+
resp.status_code = 200
220+
resp._content = plain
221+
original_id = id(resp._content)
222+
223+
_ensure_decompressed(resp)
224+
225+
assert resp.content is plain or resp.content == plain
226+
assert id(resp._content) == original_id
227+
228+
def test_json_result_batch_load_with_gzip_response(self):
229+
"""JSONResultBatch._load should succeed even when the HTTP layer didn't decompress."""
230+
rows = [["val1", 1], ["val2", 2], ["val3", 3]]
231+
raw_gz = _make_gzip_json_rows(*rows)
232+
233+
resp = _make_gzip_response(raw_gz)
234+
_ensure_decompressed(resp)
235+
236+
batch = JSONResultBatch(
237+
rowcount=len(rows),
238+
chunk_headers=None,
239+
remote_chunk_info=None,
240+
schema=[],
241+
column_converters=[],
242+
use_dict_result=False,
243+
)
244+
loaded = batch._load(resp)
245+
assert loaded == rows
246+
247+
def test_concurrent_multichunk_download_with_gzip_responses(self):
248+
"""Reproduce the reported issue: concurrent ThreadPoolExecutor downloads
249+
where each chunk response is raw gzip (no Content-Encoding header).
250+
251+
Without the _ensure_decompressed fix, json.loads() in _load() would
252+
receive \\x1f\\x8b... garbage and raise JSONDecodeError.
253+
"""
254+
num_chunks = 6
255+
rows_per_chunk = 50
256+
chunks_data = {}
257+
url_to_response = {}
258+
259+
for chunk_idx in range(num_chunks):
260+
rows = [
261+
[f"chunk{chunk_idx}_row{r}", chunk_idx * 100 + r]
262+
for r in range(rows_per_chunk)
263+
]
264+
chunk_url = f"http://fake-s3.example.com/results/chunk_{chunk_idx}"
265+
body = _make_gzip_json_rows(*rows)
266+
chunks_data[chunk_idx] = rows
267+
url_to_response[chunk_url] = _make_gzip_response(body)
268+
269+
batches = []
270+
for chunk_idx in range(num_chunks):
271+
chunk_url = f"http://fake-s3.example.com/results/chunk_{chunk_idx}"
272+
body = url_to_response[chunk_url].content
273+
batch = JSONResultBatch(
274+
rowcount=rows_per_chunk,
275+
chunk_headers=None,
276+
remote_chunk_info=RemoteChunkInfo(
277+
url=chunk_url, uncompressedSize=0, compressedSize=len(body)
278+
),
279+
schema=[],
280+
column_converters=[],
281+
use_dict_result=False,
282+
)
283+
batches.append((chunk_idx, batch))
284+
285+
def mock_get(url, **kwargs):
286+
return url_to_response[url]
287+
288+
def fetch_batch(idx_and_batch):
289+
idx, batch = idx_and_batch
290+
response = batch._download()
291+
return idx, batch._load(response)
292+
293+
all_results = {}
294+
with mock.patch(
295+
SESSION_FROM_REQUEST_MODULE_PATH + ".get", side_effect=mock_get
296+
):
297+
with ThreadPoolExecutor(max_workers=4) as pool:
298+
futures = [pool.submit(fetch_batch, ib) for ib in batches]
299+
for future in as_completed(futures):
300+
chunk_idx, loaded = future.result()
301+
all_results[chunk_idx] = loaded
302+
303+
assert len(all_results) == num_chunks
304+
for chunk_idx in range(num_chunks):
305+
assert all_results[chunk_idx] == chunks_data[chunk_idx], (
306+
f"Chunk {chunk_idx}: expected valid JSON rows but got corrupted data. "
307+
f"This indicates gzip decompression was not applied."
308+
)
309+
310+
def test_concurrent_multichunk_with_session_manager_clone(self):
311+
"""End-to-end reproduction using a cloned SessionManager, mirroring the
312+
real download path where result batches use a cloned manager without
313+
connection pooling.
314+
"""
315+
from snowflake.connector.session_manager import SessionManager
316+
317+
base_manager = SessionManager()
318+
cloned_manager = base_manager.clone(use_pooling=False)
319+
320+
rows = [["hello", 42], ["world", 99]]
321+
raw_gz = _make_gzip_json_rows(*rows)
322+
chunk_url = "http://fake-s3.example.com/results/chunk_0"
323+
324+
batch = JSONResultBatch(
325+
rowcount=len(rows),
326+
chunk_headers={},
327+
remote_chunk_info=RemoteChunkInfo(
328+
url=chunk_url, uncompressedSize=0, compressedSize=len(raw_gz)
329+
),
330+
schema=[],
331+
column_converters=[],
332+
use_dict_result=False,
333+
session_manager=cloned_manager,
334+
)
335+
336+
resp = _make_gzip_response(raw_gz)
337+
338+
with mock.patch(
339+
SESSION_FROM_REQUEST_MODULE_PATH + ".request", return_value=resp
340+
):
341+
response = batch._download()
342+
343+
loaded = batch._load(response)
344+
assert loaded == rows

0 commit comments

Comments
 (0)