fix(declarative): detect gzip payloads in GzipParser instead of trusting headers - #1124
fix(declarative): detect gzip payloads in GzipParser instead of trusting headers#1124Airbyte Support (Airbyte-Support) wants to merge 3 commits into
Conversation
Co-Authored-By: syed.khadeer@airbyte.io <cloud-support@airbyte.io>
Co-Authored-By: syed.khadeer@airbyte.io <cloud-support@airbyte.io>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This CDK VersionYou can test this version of the CDK using the following: # Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@devin/1787193372-gzip-decoder-passthrough#egg=airbyte-python-cdk[dev]' --help
# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch devin/1787193372-gzip-decoder-passthroughPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
There was a problem hiding this comment.
Pull request overview
This PR fixes inconsistent gzip handling in the declarative GzipDecoder between Connector Builder “Test read” and real syncs by making GzipParser detect gzip content from the payload (magic bytes) instead of relying on response headers.
Changes:
- Update
GzipParserto peek the first bytes of the stream, gunzip only when gzip magic bytes are present, otherwise pass the stream through unchanged (including support for non-seekable/short-read streams via a prefixed wrapper). - Make
create_gzip_decoderuseGzipParserin both Builder and sync modes (including as theby_headersfallback parser) for consistent behavior. - Add unit tests covering gzip payload detection, pass-through behavior, short-read streams, nested gzip parsers, and factory behavior across Builder vs sync modes.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
airbyte_cdk/sources/declarative/decoders/composite_raw_decoder.py |
Implements payload-based gzip detection in GzipParser and introduces a prefixed stream wrapper to support non-seekable streams. |
airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py |
Switches gzip decoder construction to use GzipParser consistently in Builder and sync modes (including as fallback). |
unit_tests/sources/declarative/decoders/test_composite_decoder.py |
Adds focused GzipParser unit tests for gzip detection, short reads, non-seekable streams, empty payloads, and nested gzip parsers. |
unit_tests/sources/declarative/parsers/test_model_to_component_factory.py |
Adds factory-level tests ensuring create_gzip_decoder handles gzip payloads across header shapes and Builder vs sync modes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| def __init__(self, prefix: bytes, stream: BufferedIOBase) -> None: | ||
| self._prefix = prefix | ||
| self._stream = stream | ||
|
|
There was a problem hiding this comment.
👍 On it. Valid — _PrefixedStream.__init__ should call super().__init__() so IOBase state (notably closed) is initialized before io.BufferedReader wraps it. Small and proportionate; adding it now and will follow up with the commit SHA.
There was a problem hiding this comment.
☑️ Resolved in 36b6431. super().__init__() is now the first statement of _PrefixedStream.__init__; unit tests, ruff and mypy all still pass.
Co-Authored-By: syed.khadeer@airbyte.io <cloud-support@airbyte.io>
Summary
Requested by Syed Khadeer (Airbyte support) off Zendesk ticket 18622: a customer's Connector Builder Test read of a stream that downloads a gzipped CSV fails with
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x8b in position 1while a real sync of the same stream succeeds.0x1f 0x8bis the gzip magic — compressed bytes are reaching the UTF-8 CSV parser.Cause:
create_gzip_decoderbuilds two different decoders depending on mode, and neither actually looks at the payload.The Builder branch assumed
requestshad already decompressedresponse.content, which only holds for transport-levelContent-Encoding: gzip. When the body is a gzip payload (e.g. an S3*.csv.gzdownload served asapplication/gziporbinary/octet-stream, as Apple App Store Connect analytics report segments are), nothing gunzips it in Builder mode. Measured onmainwith a singleGzipDecoder(CsvDecoder):Content-Type: application/gzipUnicodeDecodeError0x8bContent-Type: binary/octet-streamUnicodeDecodeError0x8bUnicodeDecodeError0x8bContent-Encoding: gzipBadGzipFileThat asymmetry is why manifests in the wild carry double-nested
GzipDecoder(GzipDecoder(CsvDecoder))workarounds (source-amazon-adsships one next to a# TODO Fix me) — and why the single- vs. double-nesting workarounds are mutually exclusive depending on which header the server returns. Previously reported in airbytehq/oncall#7739, #11173, #11809 and airbytehq/airbyte#56988.Fix: make
GzipParserself-detecting — the behavior its own docstring already promised ("If the data is not gzipped, reset the pointer and pass the data to the inner parser as is") but never implemented — and then use it in both modes.GzipParser.parsereads the 2-byte header (looping, sinceresponse.raw.read(2)may short-read), gunzips when it is\x1f\x8b, and otherwise hands the inner parser the untouched stream. Because the stream may be non-seekable (response.rawin sync mode), the consumed header is restored by wrapping it in a private_PrefixedStream(io.RawIOBase) inside anio.BufferedReader, which keeps it usable by bothgzip.GzipFileandCsvParser'sTextIOWrapper.create_gzip_decodernow passesgzip_parserin the Builder branch and as theby_headersfallback_parser, so both modes behave identically regardless ofContent-Type/Content-Encoding.A single
GzipDecodernow works for every combination in the table above, in both modes. Existing double-nested manifests keep working: the innerGzipParsersees already-decompressed data and passes it through instead of raisingBadGzipFile.Test plan
GzipParserunit tests inunit_tests/sources/declarative/decoders/test_composite_decoder.py: gzipped payload, non-gzip pass-through over both a seekable and a non-seekable stream, a stream that returns one byte perread(), empty payload, and nestedGzipParser(GzipParser(CsvParser))over a singly-gzipped body.create_gzip_decodertests inunit_tests/sources/declarative/parsers/test_model_to_component_factory.pycoveringemit_connector_builder_messagesTrueandFalseagainst each header shape above — Builder mode had no coverage at all before.poetry run pytest unit_tests/sources/declarative/decoders/ unit_tests/sources/declarative/parsers/test_model_to_component_factory.py,poetry run ruff format --check . && poetry run ruff check .,poetry run mypy --config-file mypy.ini airbyte_cdk.Link to Devin session: https://app.devin.ai/sessions/a1216bf2fe134b968ea1a3ffeb07d9aa
Requested by: Airbyte Support (@Airbyte-Support)