Skip to content

zstd multi-frame streaming decompression in client payload#12266

Open
rootvector2 wants to merge 5 commits intoaio-libs:masterfrom
rootvector2:zstd-multiframe-decompression
Open

zstd multi-frame streaming decompression in client payload#12266
rootvector2 wants to merge 5 commits intoaio-libs:masterfrom
rootvector2:zstd-multiframe-decompression

Conversation

@rootvector2
Copy link
Copy Markdown
Contributor

What do these changes do?

Fix zstd decompression for HTTP responses containing multiple frames (e.g. chunked transfer).
The decompressor is reset on frame EOF to correctly handle subsequent frames.
Adds a loop-safety guard to prevent stalled unused_data from causing infinite loops.
Includes tests for multi-frame, partial-frame, and adversarial cases.


Are there changes in behavior for the user?

Yes, previously valid zstd-encoded responses with multiple frames could raise ClientPayloadError.
These responses are now correctly decoded.

No changes to gzip, deflate, or brotli behavior.


Is it a substantial burden for the maintainers to support this?

No.
The change is limited to zstd decompression logic, follows existing patterns, and includes tests covering edge cases.
It does not introduce new APIs or long-term maintenance overhead.


Related issue number

Fixes #12234


Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes
  • If you provide code modification, please add yourself to CONTRIBUTORS.txt
  • Add a new news fragment into the CHANGES/ folder

@rootvector2 rootvector2 requested a review from asvetlov as a code owner March 22, 2026 08:20
@rootvector2 rootvector2 requested a review from webknjaz as a code owner March 22, 2026 08:23
@psf-chronographer psf-chronographer bot added the bot:chronographer:provided There is a change note present in this PR label Mar 22, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 22, 2026

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
4437 2 4435 64
View the top 2 failed test(s) by shortest run time
tests.test_compression_utils::test_zstd_decompressor_stalled_unused_data_raises
Stack Traces | 0.019s run time
obj = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>
name = 'ZstdDecompressor', ann = 'aiohttp.compression_utils'

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mannotated_getattr#x1B[39;49;00m(obj: #x1B[96mobject#x1B[39;49;00m, name: #x1B[96mstr#x1B[39;49;00m, ann: #x1B[96mstr#x1B[39;49;00m) -> #x1B[96mobject#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
        #x1B[94mtry#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
>           obj = #x1B[96mgetattr#x1B[39;49;00m(obj, name)#x1B[90m#x1B[39;49;00m
                  ^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE           AttributeError: module 'aiohttp.compression_utils' has no attribute 'ZstdDecompressor'#x1B[0m

ann        = 'aiohttp.compression_utils'
name       = 'ZstdDecompressor'
obj        = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>

#x1B[1m#x1B[.../hostedtoolcache/PyPy/3.11.13........./x64/lib/pypy3.11........./site-packages/_pytest/monkeypatch.py#x1B[0m:92: AttributeError

#x1B[33mThe above exception was the direct cause of the following exception:#x1B[0m

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x00000000269a4448>

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_zstd_decompressor_stalled_unused_data_raises#x1B[39;49;00m(#x1B[90m#x1B[39;49;00m
        monkeypatch: pytest.MonkeyPatch,#x1B[90m#x1B[39;49;00m
    ) -> #x1B[94mNone#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
        #x1B[94mclass#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[04m#x1B[92mStallingZstdDecompressor#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
            #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92m__init__#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m) -> #x1B[94mNone#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                #x1B[96mself#x1B[39;49;00m.unused_data = #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mdecompress#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, data: #x1B[96mbytes#x1B[39;49;00m, max_length: #x1B[96mint#x1B[39;49;00m) -> #x1B[96mbytes#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                #x1B[96mself#x1B[39;49;00m.unused_data = data#x1B[90m#x1B[39;49;00m
                #x1B[94mreturn#x1B[39;49;00m #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        monkeypatch.setattr(#x1B[33m"#x1B[39;49;00m#x1B[33maiohttp.compression_utils.HAS_ZSTD#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[94mTrue#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
>       monkeypatch.setattr(#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[33maiohttp.compression_utils.ZstdDecompressor#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, StallingZstdDecompressor#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m

StallingZstdDecompressor = <class 'test_compression_utils.test_zstd_decompressor_stalled_unused_data_raises.<locals>.StallingZstdDecompressor'>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x00000000269a4448>

#x1B[1m#x1B[31mtests/test_compression_utils.py#x1B[0m:55: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
#x1B[1m#x1B[.../hostedtoolcache/PyPy/3.11.13........./x64/lib/pypy3.11........./site-packages/_pytest/monkeypatch.py#x1B[0m:106: in derive_importpath
    #x1B[0mannotated_getattr(target, attr, ann=module)#x1B[90m#x1B[39;49;00m
        attr       = 'ZstdDecompressor'
        import_path = 'aiohttp.compression_utils.ZstdDecompressor'
        module     = 'aiohttp.compression_utils'
        raising    = True
        target     = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

obj = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>
name = 'ZstdDecompressor', ann = 'aiohttp.compression_utils'

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mannotated_getattr#x1B[39;49;00m(obj: #x1B[96mobject#x1B[39;49;00m, name: #x1B[96mstr#x1B[39;49;00m, ann: #x1B[96mstr#x1B[39;49;00m) -> #x1B[96mobject#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
        #x1B[94mtry#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
            obj = #x1B[96mgetattr#x1B[39;49;00m(obj, name)#x1B[90m#x1B[39;49;00m
        #x1B[94mexcept#x1B[39;49;00m #x1B[96mAttributeError#x1B[39;49;00m #x1B[94mas#x1B[39;49;00m e:#x1B[90m#x1B[39;49;00m
>           #x1B[94mraise#x1B[39;49;00m #x1B[96mAttributeError#x1B[39;49;00m(#x1B[90m#x1B[39;49;00m
                #x1B[33mf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m{#x1B[39;49;00m#x1B[96mtype#x1B[39;49;00m(obj).#x1B[91m__name__#x1B[39;49;00m#x1B[33m!r}#x1B[39;49;00m#x1B[33m object at #x1B[39;49;00m#x1B[33m{#x1B[39;49;00mann#x1B[33m}#x1B[39;49;00m#x1B[33m has no attribute #x1B[39;49;00m#x1B[33m{#x1B[39;49;00mname#x1B[33m!r}#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
            ) #x1B[94mfrom#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[04m#x1B[96me#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE           AttributeError: 'module' object at aiohttp.compression_utils has no attribute 'ZstdDecompressor'#x1B[0m

ann        = 'aiohttp.compression_utils'
name       = 'ZstdDecompressor'
obj        = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>

#x1B[1m#x1B[.../hostedtoolcache/PyPy/3.11.13........./x64/lib/pypy3.11........./site-packages/_pytest/monkeypatch.py#x1B[0m:94: AttributeError
tests.test_compression_utils::test_zstd_decompressor_allows_single_unchanged_unused_data_rollover
Stack Traces | 0.035s run time
obj = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>
name = 'ZstdDecompressor', ann = 'aiohttp.compression_utils'

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mannotated_getattr#x1B[39;49;00m(obj: #x1B[96mobject#x1B[39;49;00m, name: #x1B[96mstr#x1B[39;49;00m, ann: #x1B[96mstr#x1B[39;49;00m) -> #x1B[96mobject#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
        #x1B[94mtry#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
>           obj = #x1B[96mgetattr#x1B[39;49;00m(obj, name)#x1B[90m#x1B[39;49;00m
                  ^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE           AttributeError: module 'aiohttp.compression_utils' has no attribute 'ZstdDecompressor'#x1B[0m

ann        = 'aiohttp.compression_utils'
name       = 'ZstdDecompressor'
obj        = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>

#x1B[1m#x1B[.../hostedtoolcache/PyPy/3.11.13........./x64/lib/pypy3.11........./site-packages/_pytest/monkeypatch.py#x1B[0m:92: AttributeError

#x1B[33mThe above exception was the direct cause of the following exception:#x1B[0m

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x00007f2800f59da8>

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_zstd_decompressor_allows_single_unchanged_unused_data_rollover#x1B[39;49;00m(#x1B[90m#x1B[39;49;00m
        monkeypatch: pytest.MonkeyPatch,#x1B[90m#x1B[39;49;00m
    ) -> #x1B[94mNone#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
        #x1B[94mclass#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[04m#x1B[92mSingleRolloverZstdDecompressor#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
            _calls = #x1B[94m0#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92m__init__#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m) -> #x1B[94mNone#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                #x1B[96mself#x1B[39;49;00m.unused_data = #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mdecompress#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, data: #x1B[96mbytes#x1B[39;49;00m, max_length: #x1B[96mint#x1B[39;49;00m) -> #x1B[96mbytes#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                #x1B[96mtype#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m)._calls += #x1B[94m1#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[94mif#x1B[39;49;00m #x1B[96mtype#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m)._calls == #x1B[94m1#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                    #x1B[96mself#x1B[39;49;00m.unused_data = data#x1B[90m#x1B[39;49;00m
                    #x1B[94mreturn#x1B[39;49;00m #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
                #x1B[96mself#x1B[39;49;00m.unused_data = #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[94mreturn#x1B[39;49;00m #x1B[33mb#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33mdecoded#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        monkeypatch.setattr(#x1B[33m"#x1B[39;49;00m#x1B[33maiohttp.compression_utils.HAS_ZSTD#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[94mTrue#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
>       monkeypatch.setattr(#x1B[90m#x1B[39;49;00m
            #x1B[33m"#x1B[39;49;00m#x1B[33maiohttp.compression_utils.ZstdDecompressor#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, SingleRolloverZstdDecompressor#x1B[90m#x1B[39;49;00m
        )#x1B[90m#x1B[39;49;00m

SingleRolloverZstdDecompressor = <class 'test_compression_utils.test_zstd_decompressor_allows_single_unchanged_unused_data_rollover.<locals>.SingleRolloverZstdDecompressor'>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x00007f2800f59da8>

#x1B[1m#x1B[31mtests/test_compression_utils.py#x1B[0m:83: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
#x1B[1m#x1B[.../hostedtoolcache/PyPy/3.11.13........./x64/lib/pypy3.11........./site-packages/_pytest/monkeypatch.py#x1B[0m:106: in derive_importpath
    #x1B[0mannotated_getattr(target, attr, ann=module)#x1B[90m#x1B[39;49;00m
        attr       = 'ZstdDecompressor'
        import_path = 'aiohttp.compression_utils.ZstdDecompressor'
        module     = 'aiohttp.compression_utils'
        raising    = True
        target     = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

obj = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>
name = 'ZstdDecompressor', ann = 'aiohttp.compression_utils'

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mannotated_getattr#x1B[39;49;00m(obj: #x1B[96mobject#x1B[39;49;00m, name: #x1B[96mstr#x1B[39;49;00m, ann: #x1B[96mstr#x1B[39;49;00m) -> #x1B[96mobject#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
        #x1B[94mtry#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
            obj = #x1B[96mgetattr#x1B[39;49;00m(obj, name)#x1B[90m#x1B[39;49;00m
        #x1B[94mexcept#x1B[39;49;00m #x1B[96mAttributeError#x1B[39;49;00m #x1B[94mas#x1B[39;49;00m e:#x1B[90m#x1B[39;49;00m
>           #x1B[94mraise#x1B[39;49;00m #x1B[96mAttributeError#x1B[39;49;00m(#x1B[90m#x1B[39;49;00m
                #x1B[33mf#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[33m{#x1B[39;49;00m#x1B[96mtype#x1B[39;49;00m(obj).#x1B[91m__name__#x1B[39;49;00m#x1B[33m!r}#x1B[39;49;00m#x1B[33m object at #x1B[39;49;00m#x1B[33m{#x1B[39;49;00mann#x1B[33m}#x1B[39;49;00m#x1B[33m has no attribute #x1B[39;49;00m#x1B[33m{#x1B[39;49;00mname#x1B[33m!r}#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
            ) #x1B[94mfrom#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[04m#x1B[96me#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE           AttributeError: 'module' object at aiohttp.compression_utils has no attribute 'ZstdDecompressor'#x1B[0m

ann        = 'aiohttp.compression_utils'
name       = 'ZstdDecompressor'
obj        = <module 'aiohttp.compression_utils' from '.../aiohttp/aiohttp/compression_utils.py'>

#x1B[1m#x1B[.../hostedtoolcache/PyPy/3.11.13........./x64/lib/pypy3.11........./site-packages/_pytest/monkeypatch.py#x1B[0m:94: AttributeError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 22, 2026

Merging this PR will not alter performance

✅ 59 untouched benchmarks


Comparing rootvector2:zstd-multiframe-decompression (6d7855b) with master (2602b71)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClientPayloadError: 400, message: Can not decode content-encoding: zstd - Already at the end of a Zstandard frame

1 participant