|
39 | 39 | "cert": "cert=... is deprecated by httpx2; pass verify=<ssl.SSLContext> configured with .load_cert_chain().", |
40 | 40 | "event_hooks": "event_hooks=... is not supported; use middleware=... instead.", |
41 | 41 | } |
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." |
46 | 43 | _BASE_URL_QUERY_MESSAGE = ( |
47 | 44 | "base_url must not contain a query string: httpx2 appends request paths after it, " |
48 | 45 | "producing malformed URLs. Pass the query as params=... instead." |
@@ -118,12 +115,10 @@ def _select_httpx2_options( |
118 | 115 | supported: frozenset[str], |
119 | 116 | *, |
120 | 117 | httpx2_client: httpx2.Client | httpx2.AsyncClient | None, |
121 | | - max_response_body_bytes: int | None, |
122 | 118 | ) -> dict[str, typing.Any]: |
123 | 119 | """Return the options to forward to the owned httpx2 client, dropping unset ones. |
124 | 120 |
|
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`. |
127 | 122 | """ |
128 | 123 | unsupported = sorted(options.keys() - supported) |
129 | 124 | if unsupported: |
@@ -135,12 +130,67 @@ def _select_httpx2_options( |
135 | 130 | forwarded = {name: value for name, value in options.items() if not _is_unset(value)} |
136 | 131 | if httpx2_client is not None and forwarded: |
137 | 132 | 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) |
141 | 133 | return forwarded |
142 | 134 |
|
143 | 135 |
|
| 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 | + |
144 | 194 | def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from httpx2 call signatures |
145 | 195 | *, |
146 | 196 | params: typing.Any | None, |
@@ -211,7 +261,6 @@ def __init__( |
211 | 261 | httpx2_options, |
212 | 262 | _AsyncClientOptions.__optional_keys__, |
213 | 263 | httpx2_client=httpx2_client, |
214 | | - max_response_body_bytes=max_response_body_bytes, |
215 | 264 | ) |
216 | 265 | if httpx2_client is not None: |
217 | 266 | _reject_base_url_query(httpx2_client.base_url) |
@@ -235,9 +284,9 @@ async def _terminal(self, request: httpx2.Request) -> httpx2.Response: |
235 | 284 | if cap is None: |
236 | 285 | response = await self._httpx2_client.send(request) |
237 | 286 | else: |
238 | | - streaming = await self._httpx2_client.send(request, stream=True) |
| 287 | + streaming = await _send_following_redirects_async(self._httpx2_client, request) |
239 | 288 | try: |
240 | | - response = await _read_capped_async(streaming, cap, request) |
| 289 | + response = await _read_capped_async(streaming, cap, streaming.request) |
241 | 290 | finally: |
242 | 291 | await streaming.aclose() |
243 | 292 | except RuntimeError as exc: |
@@ -1087,9 +1136,14 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa |
1087 | 1136 | files=files, |
1088 | 1137 | ) |
1089 | 1138 |
|
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: |
1091 | 1146 | 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 |
1093 | 1147 | if cap is None: |
1094 | 1148 | await response.aread() # pre-read body so exc.response.content works |
1095 | 1149 | _raise_on_status_error(response) |
@@ -1148,7 +1202,6 @@ def __init__( |
1148 | 1202 | httpx2_options, |
1149 | 1203 | _ClientOptions.__optional_keys__, |
1150 | 1204 | httpx2_client=httpx2_client, |
1151 | | - max_response_body_bytes=max_response_body_bytes, |
1152 | 1205 | ) |
1153 | 1206 | if httpx2_client is not None: |
1154 | 1207 | _reject_base_url_query(httpx2_client.base_url) |
@@ -1172,9 +1225,9 @@ def _terminal(self, request: httpx2.Request) -> httpx2.Response: |
1172 | 1225 | if cap is None: |
1173 | 1226 | response = self._httpx2_client.send(request) |
1174 | 1227 | else: |
1175 | | - streaming = self._httpx2_client.send(request, stream=True) |
| 1228 | + streaming = _send_following_redirects(self._httpx2_client, request) |
1176 | 1229 | try: |
1177 | | - response = _read_capped(streaming, cap, request) |
| 1230 | + response = _read_capped(streaming, cap, streaming.request) |
1178 | 1231 | finally: |
1179 | 1232 | streaming.close() |
1180 | 1233 | except RuntimeError as exc: |
@@ -2045,9 +2098,14 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo |
2045 | 2098 | files=files, |
2046 | 2099 | ) |
2047 | 2100 |
|
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: |
2049 | 2108 | 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 |
2051 | 2109 | if cap is None: |
2052 | 2110 | response.read() # pre-read body so exc.response.content works |
2053 | 2111 | _raise_on_status_error(response) |
|