Skip to content

Commit 67d742c

Browse files
authored
feat: follow redirects under the response body cap (#152)
* feat: follow redirects under the response body cap With max_response_body_bytes set and follow_redirects=True, httpware now follows redirects itself, closing intermediate responses unread and capping only the final one, instead of rejecting the combination. * test: assert auth on each redirect hop; docs: state the exact Authorization rule * test: contrast intermediate redirect body reads with and without a cap
1 parent 05a4319 commit 67d742c

4 files changed

Lines changed: 378 additions & 42 deletions

File tree

‎docs/errors.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ Unlike `DecodeError`, this error is raised before the request is sent.
186186

187187
## `ResponseTooLargeError`
188188

189-
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. Setting the cap together with `follow_redirects=True`, on the client or on a passed `httpx2_client`, raises `ValueError`, because `httpx2` reads every intermediate redirect body without the cap.
189+
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. With a cap set and `follow_redirects=True`, httpware follows the redirects itself and caps only the final response. It closes each intermediate redirect response without reading its body, so the responses in `response.history` have no content. 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.
190190

191191
`ResponseTooLargeError` carries:
192192

‎src/httpware/client.py‎

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,7 @@
3939
"cert": "cert=... is deprecated by httpx2; pass verify=<ssl.SSLContext> configured with .load_cert_chain().",
4040
"event_hooks": "event_hooks=... is not supported; use middleware=... instead.",
4141
}
42-
_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE = (
43-
"follow_redirects=True cannot be combined with max_response_body_bytes: httpx2 reads every "
44-
"intermediate redirect body without the cap."
45-
)
42+
_TOO_MANY_REDIRECTS_MESSAGE = "Exceeded maximum allowed redirects."
4643
_BASE_URL_QUERY_MESSAGE = (
4744
"base_url must not contain a query string: httpx2 appends request paths after it, "
4845
"producing malformed URLs. Pass the query as params=... instead."
@@ -118,12 +115,10 @@ def _select_httpx2_options(
118115
supported: frozenset[str],
119116
*,
120117
httpx2_client: httpx2.Client | httpx2.AsyncClient | None,
121-
max_response_body_bytes: int | None,
122118
) -> dict[str, typing.Any]:
123119
"""Return the options to forward to the owned httpx2 client, dropping unset ones.
124120
125-
Raise TypeError for unsupported options or options combined with `httpx2_client`, and
126-
ValueError when the client would follow redirects under a body cap.
121+
Raise TypeError for unsupported options or options combined with `httpx2_client`.
127122
"""
128123
unsupported = sorted(options.keys() - supported)
129124
if unsupported:
@@ -135,12 +130,67 @@ def _select_httpx2_options(
135130
forwarded = {name: value for name, value in options.items() if not _is_unset(value)}
136131
if httpx2_client is not None and forwarded:
137132
raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE.format(names=sorted(forwarded)))
138-
follows = httpx2_client.follow_redirects if httpx2_client is not None else forwarded.get("follow_redirects")
139-
if follows and max_response_body_bytes is not None:
140-
raise ValueError(_FOLLOW_REDIRECTS_WITH_BODY_CAP_MESSAGE)
141133
return forwarded
142134

143135

136+
async def _send_following_redirects_async(client: httpx2.AsyncClient, request: httpx2.Request) -> httpx2.Response:
137+
"""Send `request` streaming, following redirects hop by hop without reading intermediate bodies."""
138+
history: list[httpx2.Response] = []
139+
response = await client.send(request, stream=True, follow_redirects=False)
140+
while client.follow_redirects and response.next_request is not None:
141+
await response.aclose()
142+
history.append(response)
143+
if len(history) > client.max_redirects:
144+
raise httpx2.TooManyRedirects(_TOO_MANY_REDIRECTS_MESSAGE, request=response.next_request)
145+
response = await client.send(response.next_request, stream=True, follow_redirects=False, auth=None)
146+
response.history = history
147+
return response
148+
149+
150+
@contextlib.asynccontextmanager
151+
async def _stream_following_redirects_async(
152+
client: httpx2.AsyncClient,
153+
method: str,
154+
url: httpx2.URL | str,
155+
kwargs: dict[str, typing.Any],
156+
) -> AsyncIterator[httpx2.Response]:
157+
"""Async mirror of `httpx2.AsyncClient.stream` that follows redirects via `_send_following_redirects_async`."""
158+
response = await _send_following_redirects_async(client, client.build_request(method, url, **kwargs))
159+
try:
160+
yield response
161+
finally:
162+
await response.aclose()
163+
164+
165+
def _send_following_redirects(client: httpx2.Client, request: httpx2.Request) -> httpx2.Response:
166+
"""Sync mirror of `_send_following_redirects_async`."""
167+
history: list[httpx2.Response] = []
168+
response = client.send(request, stream=True, follow_redirects=False)
169+
while client.follow_redirects and response.next_request is not None:
170+
response.close()
171+
history.append(response)
172+
if len(history) > client.max_redirects:
173+
raise httpx2.TooManyRedirects(_TOO_MANY_REDIRECTS_MESSAGE, request=response.next_request)
174+
response = client.send(response.next_request, stream=True, follow_redirects=False, auth=None)
175+
response.history = history
176+
return response
177+
178+
179+
@contextlib.contextmanager
180+
def _stream_following_redirects(
181+
client: httpx2.Client,
182+
method: str,
183+
url: httpx2.URL | str,
184+
kwargs: dict[str, typing.Any],
185+
) -> Iterator[httpx2.Response]:
186+
"""Sync mirror of `_stream_following_redirects_async`."""
187+
response = _send_following_redirects(client, client.build_request(method, url, **kwargs))
188+
try:
189+
yield response
190+
finally:
191+
response.close()
192+
193+
144194
def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from httpx2 call signatures
145195
*,
146196
params: typing.Any | None,
@@ -211,7 +261,6 @@ def __init__(
211261
httpx2_options,
212262
_AsyncClientOptions.__optional_keys__,
213263
httpx2_client=httpx2_client,
214-
max_response_body_bytes=max_response_body_bytes,
215264
)
216265
if httpx2_client is not None:
217266
_reject_base_url_query(httpx2_client.base_url)
@@ -235,9 +284,9 @@ async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
235284
if cap is None:
236285
response = await self._httpx2_client.send(request)
237286
else:
238-
streaming = await self._httpx2_client.send(request, stream=True)
287+
streaming = await _send_following_redirects_async(self._httpx2_client, request)
239288
try:
240-
response = await _read_capped_async(streaming, cap, request)
289+
response = await _read_capped_async(streaming, cap, streaming.request)
241290
finally:
242291
await streaming.aclose()
243292
except RuntimeError as exc:
@@ -1087,9 +1136,14 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa
10871136
files=files,
10881137
)
10891138

1090-
async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, merged_url, **kwargs) as response:
1139+
cap = self._max_response_body_bytes
1140+
opened = (
1141+
self._httpx2_client.stream(method, merged_url, **kwargs)
1142+
if cap is None
1143+
else _stream_following_redirects_async(self._httpx2_client, method, merged_url, kwargs)
1144+
)
1145+
async with _httpx2_exception_mapper(), opened as response:
10911146
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
1092-
cap = self._max_response_body_bytes
10931147
if cap is None:
10941148
await response.aread() # pre-read body so exc.response.content works
10951149
_raise_on_status_error(response)
@@ -1148,7 +1202,6 @@ def __init__(
11481202
httpx2_options,
11491203
_ClientOptions.__optional_keys__,
11501204
httpx2_client=httpx2_client,
1151-
max_response_body_bytes=max_response_body_bytes,
11521205
)
11531206
if httpx2_client is not None:
11541207
_reject_base_url_query(httpx2_client.base_url)
@@ -1172,9 +1225,9 @@ def _terminal(self, request: httpx2.Request) -> httpx2.Response:
11721225
if cap is None:
11731226
response = self._httpx2_client.send(request)
11741227
else:
1175-
streaming = self._httpx2_client.send(request, stream=True)
1228+
streaming = _send_following_redirects(self._httpx2_client, request)
11761229
try:
1177-
response = _read_capped(streaming, cap, request)
1230+
response = _read_capped(streaming, cap, streaming.request)
11781231
finally:
11791232
streaming.close()
11801233
except RuntimeError as exc:
@@ -2045,9 +2098,14 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo
20452098
files=files,
20462099
)
20472100

2048-
with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, merged_url, **kwargs) as response:
2101+
cap = self._max_response_body_bytes
2102+
opened = (
2103+
self._httpx2_client.stream(method, merged_url, **kwargs)
2104+
if cap is None
2105+
else _stream_following_redirects(self._httpx2_client, method, merged_url, kwargs)
2106+
)
2107+
with _httpx2_exception_mapper_sync(), opened as response:
20492108
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
2050-
cap = self._max_response_body_bytes
20512109
if cap is None:
20522110
response.read() # pre-read body so exc.response.content works
20532111
_raise_on_status_error(response)

0 commit comments

Comments
 (0)