Skip to content

Commit 1555586

Browse files
committed
feat(client): mark requests with async-iterable bodies via extensions
Adds a _is_streaming_body helper and a marker step in _request_with_body: when content / data / files is an async-iterable, set request.extensions['httpware.streaming_body'] = True before sending. Sets up Task 3: Retry will read the marker and refuse to retry streamed-body requests (they can't replay across attempts). Today the marker has no consumer; it's harmless metadata.
1 parent be919c2 commit 1555586

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

src/httpware/client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ def _raise_on_status_error(response: httpx2.Response) -> None:
7171
raise exc_class(response)
7272

7373

74+
def _is_streaming_body(value: typing.Any) -> bool:
75+
"""Return True if value is an async-iterable that cannot be safely replayed for retry."""
76+
if value is None:
77+
return False
78+
if isinstance(value, (bytes, bytearray, memoryview, str, dict)):
79+
return False
80+
return hasattr(value, "__aiter__")
81+
82+
7483
class AsyncClient:
7584
"""Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware."""
7685

@@ -164,7 +173,7 @@ def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.R
164173
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
165174
return self._httpx2_client.build_request(method, url, **kwargs)
166175

167-
async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures
176+
async def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
168177
self,
169178
method: str,
170179
url: str,
@@ -200,6 +209,8 @@ async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method sig
200209
if files is not None:
201210
kwargs["files"] = files
202211
request = self._httpx2_client.build_request(method, url, **kwargs)
212+
if _is_streaming_body(content) or _is_streaming_body(data) or _is_streaming_body(files):
213+
request.extensions["httpware.streaming_body"] = True
203214
return await self.send(request, response_model=response_model)
204215

205216
@typing.overload

tests/test_retry.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
import asyncio
88
import datetime
99
import email.utils
10+
import typing
1011
from collections.abc import Callable
1112
from http import HTTPStatus
1213

1314
import httpx2
1415
import pytest
1516

1617
from httpware import AsyncClient, NotFoundError, ServiceUnavailableError, TransportError
18+
from httpware.client import _is_streaming_body
1719
from httpware.errors import NetworkError, RetryBudgetExhaustedError
1820
from httpware.errors import TimeoutError as HttpwareTimeoutError
1921
from httpware.middleware.resilience.budget import RetryBudget
@@ -441,3 +443,80 @@ async def test_explicit_budget_shared_across_retry_instances() -> None:
441443
for _ in range(10):
442444
assert shared.try_withdraw() is True
443445
assert shared.try_withdraw() is False
446+
447+
448+
async def test_client_post_with_async_iterable_content_marks_extensions() -> None:
449+
"""Posting with an async-iterable body sets the httpware.streaming_body marker on request.extensions."""
450+
seen_extensions: list[dict[str, object]] = []
451+
452+
def handler(request: httpx2.Request) -> httpx2.Response:
453+
seen_extensions.append(dict(request.extensions))
454+
return httpx2.Response(HTTPStatus.OK, request=request)
455+
456+
async def streamed_body() -> typing.AsyncIterator[bytes]:
457+
yield b"chunk1"
458+
yield b"chunk2"
459+
460+
transport = httpx2.MockTransport(handler)
461+
client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport))
462+
await client.post("https://example.test/upload", content=streamed_body())
463+
464+
assert len(seen_extensions) == 1
465+
assert seen_extensions[0].get("httpware.streaming_body") is True
466+
467+
468+
async def test_client_post_with_bytes_content_does_not_mark_extensions() -> None:
469+
seen_extensions: list[dict[str, object]] = []
470+
471+
def handler(request: httpx2.Request) -> httpx2.Response:
472+
seen_extensions.append(dict(request.extensions))
473+
return httpx2.Response(HTTPStatus.OK, request=request)
474+
475+
transport = httpx2.MockTransport(handler)
476+
client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport))
477+
await client.post("https://example.test/upload", content=b"hi")
478+
479+
assert len(seen_extensions) == 1
480+
assert "httpware.streaming_body" not in seen_extensions[0]
481+
482+
483+
async def test_client_post_with_dict_data_does_not_mark_extensions() -> None:
484+
seen_extensions: list[dict[str, object]] = []
485+
486+
def handler(request: httpx2.Request) -> httpx2.Response:
487+
seen_extensions.append(dict(request.extensions))
488+
return httpx2.Response(HTTPStatus.OK, request=request)
489+
490+
transport = httpx2.MockTransport(handler)
491+
client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport))
492+
await client.post("https://example.test/upload", data={"k": "v"})
493+
494+
assert len(seen_extensions) == 1
495+
assert "httpware.streaming_body" not in seen_extensions[0]
496+
497+
498+
async def test_client_post_with_async_iterable_data_marks_extensions() -> None:
499+
seen_extensions: list[dict[str, object]] = []
500+
501+
def handler(request: httpx2.Request) -> httpx2.Response:
502+
seen_extensions.append(dict(request.extensions))
503+
return httpx2.Response(HTTPStatus.OK, request=request)
504+
505+
async def streamed_data() -> typing.AsyncIterator[bytes]:
506+
yield b"x"
507+
508+
transport = httpx2.MockTransport(handler)
509+
client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport))
510+
await client.post("https://example.test/upload", data=streamed_data())
511+
512+
assert len(seen_extensions) == 1
513+
assert seen_extensions[0].get("httpware.streaming_body") is True
514+
515+
516+
def test_is_streaming_body_true_for_async_iterable_files() -> None:
517+
"""_is_streaming_body returns True for an async-iterable, covering the files= path."""
518+
519+
async def streamed_files() -> typing.AsyncIterator[bytes]:
520+
yield b"x" # pragma: no cover
521+
522+
assert _is_streaming_body(streamed_files()) is True

0 commit comments

Comments
 (0)