Skip to content

Commit 6069883

Browse files
authored
fix: capped responses keep elapsed, next_request and default_encoding; capped redirect and auth hops reuse their connection (#179)
1 parent 7b41094 commit 6069883

7 files changed

Lines changed: 299 additions & 43 deletions

File tree

‎docs/adr/0009-the-body-cap-counts-decoded-bytes.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
status. `Content-Length` may reject early but never admit early, because it reports the compressed
55
size and a 1000:1 gzip bomb passes a header check by construction. It replaced an error-only
66
pre-check, since memory exhaustion has no status code, and it never applies to caller-driven
7-
`stream()` iteration, whose purpose is bodies too large to buffer. The rebuild through
8-
`httpx2.Response(content=...)` uses only public API and loses `response.elapsed` when a cap is set.
9-
That was accepted over reaching into httpx2 internals.
7+
`stream()` iteration, whose purpose is bodies too large to buffer. The response is rebuilt
8+
through `httpx2.Response(content=...)` with public API only: `elapsed`, `next_request` and
9+
`default_encoding` are copied over, and `num_bytes_downloaded`, which has no public setter, reads 0.
10+
Counting bytes in a wrapper around the response stream would avoid the rebuild; that is deferred to
11+
[#170](https://github.com/modern-python/httpware/issues/170).

‎docs/errors.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,9 @@ Unlike `DecodeError`, this error is raised before the request is sent.
200200

201201
## `ResponseTooLargeError`
202202

203-
Both clients accept `max_response_body_bytes: int | None = None`. By default there is no limit. When it is set, a response body larger than the cap raises `ResponseTooLargeError` instead of being returned, whatever the status: a `200` trips it as easily as a `500`. The cap counts decoded bytes, after decompression. It applies to `send()` and the verb methods, and to the error body that `stream()` reads before raising a `StatusError`. Bytes you read yourself while iterating a `stream()` are never capped, except with an `auth` that sets `requires_response_body`: that auth needs every body, so `stream()` reads the final one under the cap before yielding it. With a cap set, httpware runs the client's `auth` flow and, with `follow_redirects=True`, follows the redirects itself, and caps only the final response. It closes each intermediate response, such as a redirect or a `DigestAuth` challenge, without reading its body, so the responses in `response.history` have no content. An `auth` that sets `requires_response_body`, like a token refresh that parses the token endpoint's JSON, gets each response read under the cap instead. Client `auth` is sent to the first URL only. `httpx2` keeps its `Authorization` header on a redirect within the same origin or from `http` to `https` on the same host, and drops it otherwise.
203+
Both clients accept `max_response_body_bytes: int | None = None`. By default there is no limit. When it is set, a response body larger than the cap raises `ResponseTooLargeError` instead of being returned, whatever the status: a `200` trips it as easily as a `500`. The cap counts decoded bytes, after decompression. It applies to `send()` and the verb methods, and to the error body that `stream()` reads before raising a `StatusError`. Bytes you read yourself while iterating a `stream()` are never capped, except with an `auth` that sets `requires_response_body`: that auth needs every body, so `stream()` reads the final one under the cap before yielding it. With a cap set, httpware runs the client's `auth` flow and, with `follow_redirects=True`, follows the redirects itself, and caps only the final response. It reads the body of each intermediate response, such as a redirect or a `DigestAuth` challenge, and throws it away, so the connection can be reused and the responses in `response.history` have no content. An intermediate body declared or read past the cap is not an error: httpware stops reading it and closes that connection. An `auth` that sets `requires_response_body`, like a token refresh that parses the token endpoint's JSON, gets each response read under the cap instead. Client `auth` is sent to the first URL only. `httpx2` keeps its `Authorization` header on a redirect within the same origin or from `http` to `https` on the same host, and drops it otherwise.
204+
205+
A response read under the cap is rebuilt from its decoded body. It keeps `elapsed`, `next_request` and the client's `default_encoding`. It loses the `Content-Encoding` and `Transfer-Encoding` headers, its `Content-Length` is the decoded size, and `num_bytes_downloaded` is `0`.
204206

205207
`ResponseTooLargeError` carries:
206208

‎src/httpware/_internal/body_cap.py‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Response-body cap enforcement: validate, read-capped (sync + async)."""
22

3+
import contextlib
34
import typing
45
from collections.abc import Mapping
56
from http import HTTPStatus
@@ -98,24 +99,29 @@ def _reject_if_declared_length_exceeds_cap(response: httpx2.Response, cap: int)
9899

99100

100101
def _build_buffered_response(response: httpx2.Response, content: bytes, request: httpx2.Request) -> httpx2.Response:
101-
"""Rebuild a buffered Response from the original streaming `response` and the final decoded `content`."""
102-
return httpx2.Response(
102+
"""Rebuild a buffered Response from the closed streaming `response` and its final decoded `content`."""
103+
buffered = httpx2.Response(
103104
status_code=response.status_code,
104105
headers=_buffered_headers(response.headers),
105106
content=content,
106107
request=request,
107108
extensions=_safe_extensions(response.extensions),
108109
history=response.history,
110+
default_encoding=response.default_encoding,
109111
)
112+
buffered.next_request = response.next_request
113+
with contextlib.suppress(RuntimeError): # httpx2 has no elapsed for a stream it never closed
114+
buffered.elapsed = response.elapsed
115+
return buffered
110116

111117

112118
def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
113119
"""Buffer a streaming sync `response` under `cap` decoded bytes; return a buffered Response.
114120
115121
Raises `ResponseTooLargeError` (reason="declared") if the declared
116122
Content-Length already exceeds `cap` — before any byte is read — and
117-
(reason="streamed") if the decoded body crosses `cap` mid-read. Does not
118-
close `response`; the caller owns the stream lifecycle.
123+
(reason="streamed") if the decoded body crosses `cap` mid-read. Closes
124+
`response` once its whole body is read; on any other path the caller closes it.
119125
"""
120126
if not _response_has_body(request.method, response.status_code):
121127
response.read() # empty body; preserve the original response (and its headers)
@@ -127,6 +133,7 @@ def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -
127133
raise ResponseTooLargeError(
128134
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
129135
) from None
136+
response.close()
130137
return _build_buffered_response(response, content, request)
131138

132139

@@ -143,4 +150,36 @@ async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx
143150
raise ResponseTooLargeError(
144151
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
145152
)
153+
await response.aclose()
146154
return _build_buffered_response(response, bytes(buf), request)
155+
156+
157+
def _fits_declared_length(response: httpx2.Response, cap: int) -> bool:
158+
"""Whether `response` may carry a body within `cap`: no body, or no Content-Length above it."""
159+
if not _response_has_body(response.request.method, response.status_code):
160+
return True
161+
content_length = _parse_content_length(response.headers.get("content-length"))
162+
return content_length is None or content_length <= cap
163+
164+
165+
def _discard_within_cap(response: httpx2.Response, cap: int) -> None:
166+
"""Read and discard `response`'s body, stopping once it is declared or read past `cap` decoded bytes."""
167+
if not _fits_declared_length(response, cap):
168+
return
169+
read = 0
170+
for chunk in response.iter_bytes():
171+
read += len(chunk)
172+
if read > cap:
173+
return
174+
175+
176+
async def _discard_within_cap_async(response: httpx2.Response, cap: int) -> None:
177+
"""Async mirror of `_discard_within_cap`."""
178+
if not _fits_declared_length(response, cap):
179+
return
180+
read = 0
181+
async with contextlib.aclosing(response.aiter_bytes()) as chunks:
182+
async for chunk in chunks:
183+
read += len(chunk)
184+
if read > cap:
185+
return

‎src/httpware/client.py‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111
import typing_extensions
1212

1313
from httpware._internal import import_checker
14-
from httpware._internal.body_cap import _read_capped, _read_capped_async, _validate_max_response_body_bytes
14+
from httpware._internal.body_cap import (
15+
_discard_within_cap,
16+
_discard_within_cap_async,
17+
_read_capped,
18+
_read_capped_async,
19+
_validate_max_response_body_bytes,
20+
)
1521
from httpware._internal.exception_mapping import (
1622
_httpx2_exception_mapper,
1723
_httpx2_exception_mapper_sync,
@@ -165,12 +171,22 @@ async def _read_capped_and_close_async(streaming: httpx2.Response, cap: int) ->
165171
await streaming.aclose()
166172

167173

174+
async def _discard_and_close_async(response: httpx2.Response, cap: int) -> None:
175+
"""Close an intermediate `response`, first reading an unread body that fits `cap` so its connection is reused."""
176+
try:
177+
if not response.is_stream_consumed:
178+
await _discard_within_cap_async(response, cap)
179+
finally:
180+
await response.aclose()
181+
182+
168183
async def _send_redirect_hops_async(
169184
client: httpx2.AsyncClient,
170185
request: httpx2.Request,
171186
prior_history: list[httpx2.Response],
187+
cap: int,
172188
) -> httpx2.Response:
173-
"""Send `request` streaming, following redirects hop by hop without reading intermediate bodies.
189+
"""Send `request` streaming, following redirects hop by hop and keeping no intermediate body.
174190
175191
Histories and the `max_redirects` count include `prior_history`, as in httpx2.
176192
"""
@@ -182,13 +198,13 @@ async def _send_redirect_hops_async(
182198
response.history = list(hops)
183199
if not client.follow_redirects or response.next_request is None:
184200
return response
185-
await response.aclose()
201+
await _discard_and_close_async(response, cap)
186202
hops.append(response)
187203
request = response.next_request
188204

189205

190206
async def _send_capped_async(client: httpx2.AsyncClient, request: httpx2.Request, cap: int) -> httpx2.Response:
191-
"""Send `request` streaming, driving the client's auth flow and redirects without reading intermediate bodies.
207+
"""Send `request` streaming, driving the client's auth flow and redirects and keeping no intermediate body.
192208
193209
An auth that sets `requires_response_body` gets each response buffered under `cap` instead.
194210
"""
@@ -198,7 +214,7 @@ async def _send_capped_async(client: httpx2.AsyncClient, request: httpx2.Request
198214
try:
199215
request = await anext(flow)
200216
while True:
201-
response = await _send_redirect_hops_async(client, request, history)
217+
response = await _send_redirect_hops_async(client, request, history, cap)
202218
if auth.requires_response_body:
203219
response = await _read_capped_and_close_async(response, cap)
204220
try:
@@ -208,7 +224,7 @@ async def _send_capped_async(client: httpx2.AsyncClient, request: httpx2.Request
208224
except BaseException:
209225
await response.aclose()
210226
raise
211-
await response.aclose()
227+
await _discard_and_close_async(response, cap)
212228
response.history = list(history)
213229
history.append(response)
214230
request = next_request
@@ -252,10 +268,20 @@ def _read_capped_and_close(streaming: httpx2.Response, cap: int) -> httpx2.Respo
252268
streaming.close()
253269

254270

271+
def _discard_and_close(response: httpx2.Response, cap: int) -> None:
272+
"""Sync mirror of `_discard_and_close_async`."""
273+
try:
274+
if not response.is_stream_consumed:
275+
_discard_within_cap(response, cap)
276+
finally:
277+
response.close()
278+
279+
255280
def _send_redirect_hops(
256281
client: httpx2.Client,
257282
request: httpx2.Request,
258283
prior_history: list[httpx2.Response],
284+
cap: int,
259285
) -> httpx2.Response:
260286
"""Sync mirror of `_send_redirect_hops_async`."""
261287
hops = list(prior_history)
@@ -266,7 +292,7 @@ def _send_redirect_hops(
266292
response.history = list(hops)
267293
if not client.follow_redirects or response.next_request is None:
268294
return response
269-
response.close()
295+
_discard_and_close(response, cap)
270296
hops.append(response)
271297
request = response.next_request
272298

@@ -279,7 +305,7 @@ def _send_capped(client: httpx2.Client, request: httpx2.Request, cap: int) -> ht
279305
try:
280306
request = next(flow)
281307
while True:
282-
response = _send_redirect_hops(client, request, history)
308+
response = _send_redirect_hops(client, request, history, cap)
283309
if auth.requires_response_body:
284310
response = _read_capped_and_close(response, cap)
285311
try:
@@ -289,7 +315,7 @@ def _send_capped(client: httpx2.Client, request: httpx2.Request, cap: int) -> ht
289315
except BaseException:
290316
response.close()
291317
raise
292-
response.close()
318+
_discard_and_close(response, cap)
293319
response.history = list(history)
294320
history.append(response)
295321
request = next_request

‎tests/test_client_body_cap.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
coverage lives in tests/test_client_stream*.py.
66
"""
77

8+
import datetime as dt
89
import gzip
910
from collections.abc import AsyncIterator
11+
from http import HTTPStatus
1012

1113
import httpx2
1214
import pytest
@@ -204,3 +206,74 @@ def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
204206
request = client.build_request("GET", "https://example.test/x")
205207
assert (await client.send(request)).content == body
206208
await client.aclose()
209+
210+
211+
# ---- response attributes kept with or without a cap ----
212+
213+
214+
def _latin1_body(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
215+
return httpx2.Response(HTTPStatus.OK, headers={"content-type": "text/plain"}, content="café".encode("latin-1"))
216+
217+
218+
def _streamed_body(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
219+
return httpx2.Response(HTTPStatus.OK, content=iter([b"hello"]))
220+
221+
222+
async def _hello() -> AsyncIterator[bytes]:
223+
yield b"hello"
224+
225+
226+
def _streamed_body_async(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
227+
return httpx2.Response(HTTPStatus.OK, content=_hello())
228+
229+
230+
def _redirect(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
231+
return httpx2.Response(HTTPStatus.FOUND, headers={"location": "/next"})
232+
233+
234+
@pytest.mark.parametrize("cap", [None, 1024])
235+
def test_sync_response_keeps_elapsed_with_or_without_a_cap(cap: int | None) -> None:
236+
with Client(transport=httpx2.MockTransport(_streamed_body), max_response_body_bytes=cap) as client:
237+
response = client.get("https://example.test/x")
238+
assert response.elapsed >= dt.timedelta(0)
239+
240+
241+
@pytest.mark.parametrize("cap", [None, 1024])
242+
def test_sync_response_keeps_next_request_with_or_without_a_cap(cap: int | None) -> None:
243+
with Client(transport=httpx2.MockTransport(_redirect), max_response_body_bytes=cap) as client:
244+
response = client.get("https://example.test/x")
245+
assert response.next_request is not None
246+
assert str(response.next_request.url) == "https://example.test/next"
247+
248+
249+
@pytest.mark.parametrize("cap", [None, 1024])
250+
def test_sync_response_keeps_default_encoding_with_or_without_a_cap(cap: int | None) -> None:
251+
with Client(
252+
transport=httpx2.MockTransport(_latin1_body), default_encoding="latin-1", max_response_body_bytes=cap
253+
) as client:
254+
response = client.get("https://example.test/x")
255+
assert response.text == "café"
256+
257+
258+
@pytest.mark.parametrize("cap", [None, 1024])
259+
async def test_async_response_keeps_elapsed_with_or_without_a_cap(cap: int | None) -> None:
260+
async with AsyncClient(transport=httpx2.MockTransport(_streamed_body_async), max_response_body_bytes=cap) as client:
261+
response = await client.get("https://example.test/x")
262+
assert response.elapsed >= dt.timedelta(0)
263+
264+
265+
@pytest.mark.parametrize("cap", [None, 1024])
266+
async def test_async_response_keeps_next_request_with_or_without_a_cap(cap: int | None) -> None:
267+
async with AsyncClient(transport=httpx2.MockTransport(_redirect), max_response_body_bytes=cap) as client:
268+
response = await client.get("https://example.test/x")
269+
assert response.next_request is not None
270+
assert str(response.next_request.url) == "https://example.test/next"
271+
272+
273+
@pytest.mark.parametrize("cap", [None, 1024])
274+
async def test_async_response_keeps_default_encoding_with_or_without_a_cap(cap: int | None) -> None:
275+
async with AsyncClient(
276+
transport=httpx2.MockTransport(_latin1_body), default_encoding="latin-1", max_response_body_bytes=cap
277+
) as client:
278+
response = await client.get("https://example.test/x")
279+
assert response.text == "café"

0 commit comments

Comments
 (0)