Skip to content

Commit 4fd0c70

Browse files
authored
Decode compressed response bodies incrementally (#1126)
1 parent d588e52 commit 4fd0c70

6 files changed

Lines changed: 259 additions & 85 deletions

File tree

src/httpx2/httpx2/_client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,13 @@ def __init__(self, stream: AsyncByteStream, start: float) -> None:
160160
self.elapsed: datetime.timedelta | None = None
161161

162162
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
163-
async for chunk in self._stream:
164-
yield chunk
163+
stream = self._stream.__aiter__()
164+
try:
165+
async for chunk in stream:
166+
yield chunk
167+
finally:
168+
if isinstance(stream, AsyncGenerator):
169+
await stream.aclose()
165170

166171
async def aclose(self) -> None:
167172
self.elapsed = datetime.timedelta(seconds=time.perf_counter() - self._start)

src/httpx2/httpx2/_content.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import inspect
44
import warnings
5-
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator, Mapping
5+
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Iterable, Iterator, Mapping
66
from json import dumps as json_dumps
77
from typing import (
88
Any,
@@ -79,9 +79,15 @@ async def __aiter__(self) -> AsyncIterator[bytes]:
7979
yield chunk
8080
chunk = await self._stream.aread(self.CHUNK_SIZE)
8181
else:
82-
# Otherwise iterate.
83-
async for part in self._stream:
84-
yield part
82+
# Otherwise iterate, making sure the wrapped stream is closed even if the
83+
# consumer stops early (e.g. an exception is raised part-way through decoding).
84+
stream = self._stream.__aiter__()
85+
try:
86+
async for part in stream:
87+
yield part
88+
finally:
89+
if isinstance(stream, AsyncGenerator):
90+
await stream.aclose()
8591

8692

8793
class UnattachedStream(AsyncByteStream, SyncByteStream):

src/httpx2/httpx2/_decoders.py

Lines changed: 96 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import codecs
1010
import io
11+
import itertools
1112
import sys
1213
import typing
1314
import zlib
@@ -48,11 +49,42 @@
4849
_zstandard_installed = False
4950

5051

52+
MAX_DECODE_CHUNK_SIZE = 2**20 # 1 MiB
53+
54+
55+
class Decompressor(typing.Protocol):
56+
@property
57+
def unconsumed_tail(self) -> bytes: ...
58+
59+
def decompress(self, data: bytes, max_length: int) -> bytes: ...
60+
61+
def flush(self) -> bytes: ...
62+
63+
64+
class ZlibDecompressor:
65+
"""
66+
Drain a `zlib`/`gzip` decompressor in bounded pieces so a small compressed
67+
input cannot inflate to an unbounded buffer in a single call.
68+
"""
69+
70+
def __init__(self, decompressor: Decompressor) -> None:
71+
self.decompressor = decompressor
72+
73+
def decompress(self, data: bytes) -> typing.Iterator[bytes]:
74+
decompressed = self.decompressor.decompress(data, MAX_DECODE_CHUNK_SIZE)
75+
while decompressed:
76+
yield decompressed
77+
decompressed = self.decompressor.decompress(self.decompressor.unconsumed_tail, MAX_DECODE_CHUNK_SIZE)
78+
79+
def flush(self) -> bytes:
80+
return self.decompressor.flush()
81+
82+
5183
class ContentDecoder:
52-
def decode(self, data: bytes) -> bytes:
84+
def decode(self, data: bytes) -> typing.Iterator[bytes]:
5385
raise NotImplementedError() # pragma: no cover
5486

55-
def flush(self) -> bytes:
87+
def flush(self) -> typing.Iterator[bytes]:
5688
raise NotImplementedError() # pragma: no cover
5789

5890

@@ -61,11 +93,11 @@ class IdentityDecoder(ContentDecoder):
6193
Handle unencoded data.
6294
"""
6395

64-
def decode(self, data: bytes) -> bytes:
65-
return data
96+
def decode(self, data: bytes) -> typing.Iterator[bytes]:
97+
yield data
6698

67-
def flush(self) -> bytes:
68-
return b""
99+
def flush(self) -> typing.Iterator[bytes]:
100+
yield from ()
69101

70102

71103
class DeflateDecoder(ContentDecoder):
@@ -77,22 +109,23 @@ class DeflateDecoder(ContentDecoder):
77109

78110
def __init__(self) -> None:
79111
self.first_attempt = True
80-
self.decompressor = zlib.decompressobj()
112+
self.decompressor = ZlibDecompressor(zlib.decompressobj())
81113

82-
def decode(self, data: bytes) -> bytes:
114+
def decode(self, data: bytes) -> typing.Iterator[bytes]:
83115
was_first_attempt = self.first_attempt
84116
self.first_attempt = False
85117
try:
86-
return self.decompressor.decompress(data)
118+
yield from self.decompressor.decompress(data)
87119
except zlib.error as exc:
88120
if was_first_attempt:
89-
self.decompressor = zlib.decompressobj(-zlib.MAX_WBITS)
90-
return self.decode(data)
91-
raise DecodingError(str(exc)) from exc
121+
self.decompressor = ZlibDecompressor(zlib.decompressobj(-zlib.MAX_WBITS))
122+
yield from self.decode(data)
123+
else:
124+
raise DecodingError(str(exc)) from exc
92125

93-
def flush(self) -> bytes:
126+
def flush(self) -> typing.Iterator[bytes]:
94127
try:
95-
return self.decompressor.flush()
128+
yield self.decompressor.flush()
96129
except zlib.error as exc: # pragma: no cover
97130
raise DecodingError(str(exc)) from exc
98131

@@ -105,17 +138,17 @@ class GZipDecoder(ContentDecoder):
105138
"""
106139

107140
def __init__(self) -> None:
108-
self.decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
141+
self.decompressor = ZlibDecompressor(zlib.decompressobj(zlib.MAX_WBITS | 16))
109142

110-
def decode(self, data: bytes) -> bytes:
143+
def decode(self, data: bytes) -> typing.Iterator[bytes]:
111144
try:
112-
return self.decompressor.decompress(data)
145+
yield from self.decompressor.decompress(data)
113146
except zlib.error as exc:
114147
raise DecodingError(str(exc)) from exc
115148

116-
def flush(self) -> bytes:
149+
def flush(self) -> typing.Iterator[bytes]:
117150
try:
118-
return self.decompressor.flush()
151+
yield self.decompressor.flush()
119152
except zlib.error as exc: # pragma: no cover
120153
raise DecodingError(str(exc)) from exc
121154

@@ -140,26 +173,31 @@ def __init__(self) -> None:
140173

141174
self.decompressor = brotli.Decompressor()
142175
self.seen_data = False
143-
self._decompress: typing.Callable[[bytes], bytes]
176+
self._decompress: typing.Callable[..., bytes]
144177
if hasattr(self.decompressor, "decompress"):
145178
# The 'brotlicffi' package.
146179
self._decompress = self.decompressor.decompress # pragma: no cover
147180
else:
148181
# The 'brotli' package.
149-
self._decompress = self.decompressor.process # pragma: no cover
182+
self._decompress = self.decompressor.process
150183

151-
def decode(self, data: bytes) -> bytes:
184+
def decode(self, data: bytes) -> typing.Iterator[bytes]:
152185
if not data:
153-
return b""
186+
return
154187
self.seen_data = True
155188
try:
156-
return self._decompress(data)
189+
# The C backend may allocate nearly twice the requested threshold.
190+
output_buffer_limit = MAX_DECODE_CHUNK_SIZE // 2
191+
decompressed = self._decompress(data, output_buffer_limit=output_buffer_limit)
192+
while decompressed:
193+
yield decompressed
194+
decompressed = self._decompress(b"", output_buffer_limit=output_buffer_limit)
157195
except brotli.error as exc:
158196
raise DecodingError(str(exc)) from exc
159197

160-
def flush(self) -> bytes:
198+
def flush(self) -> typing.Iterator[bytes]:
161199
if not self.seen_data:
162-
return b""
200+
return
163201
try:
164202
if hasattr(self.decompressor, "finish"):
165203
# Only available in the 'brotlicffi' package.
@@ -168,9 +206,9 @@ def flush(self) -> bytes:
168206
# will never actually emit any data. However, it will potentially throw
169207
# errors if a truncated or damaged data stream has been used.
170208
self.decompressor.finish() # pragma: no cover
171-
return b""
172209
except brotli.error as exc: # pragma: no cover
173210
raise DecodingError(str(exc)) from exc
211+
yield from ()
174212

175213

176214
class ZStandardDecoder(ContentDecoder):
@@ -189,30 +227,34 @@ def __init__(self) -> None:
189227
self.decompressor = ZstdDecompressor()
190228
self.seen_data = False
191229

192-
def decode(self, data: bytes) -> bytes:
230+
def decode(self, data: bytes) -> typing.Iterator[bytes]:
193231
if not data:
194-
return b""
232+
return
195233
self.seen_data = True
196-
output = io.BytesIO()
197234
try:
198235
if self.decompressor.eof:
199236
data = self.decompressor.unused_data + data
200237
self.decompressor = ZstdDecompressor()
201-
output.write(self.decompressor.decompress(data))
202-
while self.decompressor.eof and self.decompressor.unused_data:
203-
unused_data = self.decompressor.unused_data
238+
while True:
239+
decompressed = self.decompressor.decompress(data, MAX_DECODE_CHUNK_SIZE)
240+
while decompressed:
241+
yield decompressed
242+
if self.decompressor.needs_input or self.decompressor.eof:
243+
break
244+
decompressed = self.decompressor.decompress(b"", MAX_DECODE_CHUNK_SIZE)
245+
if not (self.decompressor.eof and self.decompressor.unused_data):
246+
break
247+
data = self.decompressor.unused_data
204248
self.decompressor = ZstdDecompressor()
205-
output.write(self.decompressor.decompress(unused_data))
206249
except ZstdError as exc:
207250
raise DecodingError(str(exc)) from exc
208-
return output.getvalue()
209251

210-
def flush(self) -> bytes:
252+
def flush(self) -> typing.Iterator[bytes]:
211253
if not self.seen_data:
212-
return b""
254+
return
213255
if not self.decompressor.eof:
214256
raise DecodingError("Zstandard data is incomplete") # pragma: no cover
215-
return b""
257+
yield from ()
216258

217259

218260
class MultiDecoder(ContentDecoder):
@@ -233,16 +275,25 @@ def __init__(self, encodings: typing.Sequence[str]) -> None:
233275
# Note that we reverse the order for decoding.
234276
self.children: list[ContentDecoder] = [SUPPORTED_DECODERS[coding]() for coding in reversed(codings)]
235277

236-
def decode(self, data: bytes) -> bytes:
278+
def decode(self, data: bytes) -> typing.Iterator[bytes]:
279+
streams: typing.Iterator[bytes] = iter((data,))
237280
for child in self.children:
238-
data = child.decode(data)
239-
return data
281+
streams = self._pipe(child.decode, streams)
282+
yield from streams
240283

241-
def flush(self) -> bytes:
242-
data = b""
284+
def flush(self) -> typing.Iterator[bytes]:
285+
streams: typing.Iterator[bytes] = iter(())
243286
for child in self.children:
244-
data = child.decode(data) + child.flush()
245-
return data
287+
streams = itertools.chain(self._pipe(child.decode, streams), child.flush())
288+
yield from streams
289+
290+
@staticmethod
291+
def _pipe(
292+
decode: typing.Callable[[bytes], typing.Iterator[bytes]],
293+
upstream: typing.Iterator[bytes],
294+
) -> typing.Iterator[bytes]:
295+
for chunk in upstream:
296+
yield from decode(chunk)
246297

247298

248299
class ByteChunker:

0 commit comments

Comments
 (0)