From fe301af01df807839a7a85d28ad745fa2caca18d Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 12:09:58 +0000 Subject: [PATCH 1/5] Close request body async iterator on write errors If a `WriteError` is raised part-way through `_send_request_body`, the request body's async iterator was abandoned mid-iteration and later garbage collected without ever being exhausted, triggering `ResourceWarning: async generator was garbage collected before it had been exhausted` (encode/httpx#3597). Close the iterator in a `finally` block so it is cleaned up on both success and failure paths. --- httpcore/_async/http11.py | 17 +++++++++-- tests/_async/test_http11.py | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/httpcore/_async/http11.py b/httpcore/_async/http11.py index e6d6d7098..4c2d03517 100644 --- a/httpcore/_async/http11.py +++ b/httpcore/_async/http11.py @@ -154,9 +154,20 @@ async def _send_request_body(self, request: Request) -> None: timeout = timeouts.get("write", None) assert isinstance(request.stream, typing.AsyncIterable) - async for chunk in request.stream: - event = h11.Data(data=chunk) - await self._send_event(event, timeout=timeout) + body = request.stream.__aiter__() + try: + async for chunk in body: + event = h11.Data(data=chunk) + await self._send_event(event, timeout=timeout) + finally: + # If sending the body fails part-way through, for example with + # a `WriteError`, the async iterator would otherwise be abandoned + # mid-iteration, and then garbage collected without ever being + # exhausted, triggering `ResourceWarning`. + # See https://github.com/encode/httpx/issues/3597 + aclose = getattr(body, "aclose", None) + if aclose is not None: + await aclose() await self._send_event(h11.EndOfMessage(), timeout=timeout) diff --git a/tests/_async/test_http11.py b/tests/_async/test_http11.py index 94f2febf0..45d73f89b 100644 --- a/tests/_async/test_http11.py +++ b/tests/_async/test_http11.py @@ -378,3 +378,63 @@ async def test_http11_header_sub_100kb(): response = await conn.request("GET", "https://example.com/") assert response.status == 200 assert response.content == b"" + + +@pytest.mark.anyio +async def test_http11_write_error_closes_request_body(): + """ + If a `WriteError` occurs part-way through sending the request body, + the request body's async iterator must be closed rather than abandoned + mid-iteration. Otherwise it is garbage collected without ever being + exhausted, triggering `ResourceWarning`. + See https://github.com/encode/httpx/issues/3597. + """ + + class WriteErrorStream(httpcore.AsyncMockStream): + def __init__(self, buffer: list[bytes], fail_after_writes: int) -> None: + super().__init__(buffer) + self._writes = 0 + self._fail_after_writes = fail_after_writes + + async def write(self, buffer: bytes, timeout: float | None = None) -> None: + self._writes += 1 + if self._writes > self._fail_after_writes: + raise httpcore.WriteError("Simulated write failure") + await super().write(buffer, timeout) + + class TrackingBody: + """Async-iterable body, recording if its iterator gets closed.""" + + def __init__(self) -> None: + self.saw_generator_exit = False + + def __aiter__(self): + async def gen(): + try: + for index in range(10): + yield b"chunk-%d" % index + except GeneratorExit: + self.saw_generator_exit = True + raise + + return gen() + + origin = httpcore.Origin(b"https", b"example.com", 443) + # Two writes succeed: the request headers, then the first body chunk. + # The write of the second body chunk raises `WriteError`. + stream = WriteErrorStream( + [ + b"HTTP/1.1 200 OK\r\n", + b"Content-Length: 0\r\n", + b"\r\n", + ], + fail_after_writes=2, + ) + body = TrackingBody() + async with httpcore.AsyncHTTP11Connection(origin=origin, stream=stream) as conn: + response = await conn.request("POST", "https://example.com/", content=body) + # The `WriteError` is suppressed, and the response is still readable. + assert response.status == 200 + assert response.content == b"" + + assert body.saw_generator_exit From fe74739e66dc0a4c63c643cd885930ded9a00bf8 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 12:12:31 +0000 Subject: [PATCH 2/5] Regenerate _sync files and fix test typing The _sync/http11.py and _sync/test_http11.py files are generated from their _async counterparts by scripts/unasync.py (enforced by 'scripts/check' in CI). Also annotate the new test's generator so mypy is clean. --- httpcore/_sync/http11.py | 17 +++++++++-- tests/_async/test_http11.py | 33 ++++++++++----------- tests/_sync/test_http11.py | 57 +++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/httpcore/_sync/http11.py b/httpcore/_sync/http11.py index ebd3a9748..b8758fa65 100644 --- a/httpcore/_sync/http11.py +++ b/httpcore/_sync/http11.py @@ -154,9 +154,20 @@ def _send_request_body(self, request: Request) -> None: timeout = timeouts.get("write", None) assert isinstance(request.stream, typing.Iterable) - for chunk in request.stream: - event = h11.Data(data=chunk) - self._send_event(event, timeout=timeout) + body = request.stream.__iter__() + try: + for chunk in body: + event = h11.Data(data=chunk) + self._send_event(event, timeout=timeout) + finally: + # If sending the body fails part-way through, for example with + # a `WriteError`, the async iterator would otherwise be abandoned + # mid-iteration, and then garbage collected without ever being + # exhausted, triggering `ResourceWarning`. + # See https://github.com/encode/httpx/issues/3597 + close = getattr(body, "close", None) + if close is not None: + close() self._send_event(h11.EndOfMessage(), timeout=timeout) diff --git a/tests/_async/test_http11.py b/tests/_async/test_http11.py index 45d73f89b..7777940cf 100644 --- a/tests/_async/test_http11.py +++ b/tests/_async/test_http11.py @@ -1,3 +1,5 @@ +import typing + import pytest import httpcore @@ -402,22 +404,16 @@ async def write(self, buffer: bytes, timeout: float | None = None) -> None: raise httpcore.WriteError("Simulated write failure") await super().write(buffer, timeout) - class TrackingBody: - """Async-iterable body, recording if its iterator gets closed.""" - - def __init__(self) -> None: - self.saw_generator_exit = False - - def __aiter__(self): - async def gen(): - try: - for index in range(10): - yield b"chunk-%d" % index - except GeneratorExit: - self.saw_generator_exit = True - raise + body_closed = False - return gen() + async def streaming_body() -> typing.AsyncIterator[bytes]: + nonlocal body_closed + try: + for index in range(10): + yield b"chunk-%d" % index + except GeneratorExit: + body_closed = True + raise origin = httpcore.Origin(b"https", b"example.com", 443) # Two writes succeed: the request headers, then the first body chunk. @@ -430,11 +426,12 @@ async def gen(): ], fail_after_writes=2, ) - body = TrackingBody() async with httpcore.AsyncHTTP11Connection(origin=origin, stream=stream) as conn: - response = await conn.request("POST", "https://example.com/", content=body) + response = await conn.request( + "POST", "https://example.com/", content=streaming_body() + ) # The `WriteError` is suppressed, and the response is still readable. assert response.status == 200 assert response.content == b"" - assert body.saw_generator_exit + assert body_closed diff --git a/tests/_sync/test_http11.py b/tests/_sync/test_http11.py index f2fa28f4c..1c175fda6 100644 --- a/tests/_sync/test_http11.py +++ b/tests/_sync/test_http11.py @@ -1,3 +1,5 @@ +import typing + import pytest import httpcore @@ -378,3 +380,58 @@ def test_http11_header_sub_100kb(): response = conn.request("GET", "https://example.com/") assert response.status == 200 assert response.content == b"" + + + +def test_http11_write_error_closes_request_body(): + """ + If a `WriteError` occurs part-way through sending the request body, + the request body's async iterator must be closed rather than abandoned + mid-iteration. Otherwise it is garbage collected without ever being + exhausted, triggering `ResourceWarning`. + See https://github.com/encode/httpx/issues/3597. + """ + + class WriteErrorStream(httpcore.MockStream): + def __init__(self, buffer: list[bytes], fail_after_writes: int) -> None: + super().__init__(buffer) + self._writes = 0 + self._fail_after_writes = fail_after_writes + + def write(self, buffer: bytes, timeout: float | None = None) -> None: + self._writes += 1 + if self._writes > self._fail_after_writes: + raise httpcore.WriteError("Simulated write failure") + super().write(buffer, timeout) + + body_closed = False + + def streaming_body() -> typing.Iterator[bytes]: + nonlocal body_closed + try: + for index in range(10): + yield b"chunk-%d" % index + except GeneratorExit: + body_closed = True + raise + + origin = httpcore.Origin(b"https", b"example.com", 443) + # Two writes succeed: the request headers, then the first body chunk. + # The write of the second body chunk raises `WriteError`. + stream = WriteErrorStream( + [ + b"HTTP/1.1 200 OK\r\n", + b"Content-Length: 0\r\n", + b"\r\n", + ], + fail_after_writes=2, + ) + with httpcore.HTTP11Connection(origin=origin, stream=stream) as conn: + response = conn.request( + "POST", "https://example.com/", content=streaming_body() + ) + # The `WriteError` is suppressed, and the response is still readable. + assert response.status == 200 + assert response.content == b"" + + assert body_closed From 360c79dc040b749f83bdc11dbad6811f44ab6f85 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 16:41:43 +0000 Subject: [PATCH 3/5] fix(httpcore#1119): use py3.8-compatible typing in tests (typing.List/Optional) --- tests/_sync/test_http11.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/_sync/test_http11.py b/tests/_sync/test_http11.py index 1c175fda6..f5e6fe6e3 100644 --- a/tests/_sync/test_http11.py +++ b/tests/_sync/test_http11.py @@ -393,12 +393,12 @@ def test_http11_write_error_closes_request_body(): """ class WriteErrorStream(httpcore.MockStream): - def __init__(self, buffer: list[bytes], fail_after_writes: int) -> None: + def __init__(self, buffer: typing.List[bytes], fail_after_writes: int) -> None: super().__init__(buffer) self._writes = 0 self._fail_after_writes = fail_after_writes - def write(self, buffer: bytes, timeout: float | None = None) -> None: + def write(self, buffer: bytes, timeout: typing.Optional[float] = None) -> None: self._writes += 1 if self._writes > self._fail_after_writes: raise httpcore.WriteError("Simulated write failure") From ea6f3cd55494842509f092f99647216edfc03b5a Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 16:41:46 +0000 Subject: [PATCH 4/5] fix(httpcore#1119): use py3.8-compatible typing in tests (typing.List/Optional) --- tests/_async/test_http11.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/_async/test_http11.py b/tests/_async/test_http11.py index 7777940cf..3d3b27d57 100644 --- a/tests/_async/test_http11.py +++ b/tests/_async/test_http11.py @@ -393,12 +393,12 @@ async def test_http11_write_error_closes_request_body(): """ class WriteErrorStream(httpcore.AsyncMockStream): - def __init__(self, buffer: list[bytes], fail_after_writes: int) -> None: + def __init__(self, buffer: typing.List[bytes], fail_after_writes: int) -> None: super().__init__(buffer) self._writes = 0 self._fail_after_writes = fail_after_writes - async def write(self, buffer: bytes, timeout: float | None = None) -> None: + async def write(self, buffer: bytes, timeout: typing.Optional[float] = None) -> None: self._writes += 1 if self._writes > self._fail_after_writes: raise httpcore.WriteError("Simulated write failure") From d6d35b7e64b47e318abbf249672faaed7f8907a5 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 17:16:50 +0000 Subject: [PATCH 5/5] fix(httpcore#1119): wrap long line in tests for ruff format --- tests/_async/test_http11.py | 4 +++- tests/_sync/test_http11.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/_async/test_http11.py b/tests/_async/test_http11.py index 3d3b27d57..15d565532 100644 --- a/tests/_async/test_http11.py +++ b/tests/_async/test_http11.py @@ -398,7 +398,9 @@ def __init__(self, buffer: typing.List[bytes], fail_after_writes: int) -> None: self._writes = 0 self._fail_after_writes = fail_after_writes - async def write(self, buffer: bytes, timeout: typing.Optional[float] = None) -> None: + async def write( + self, buffer: bytes, timeout: typing.Optional[float] = None + ) -> None: self._writes += 1 if self._writes > self._fail_after_writes: raise httpcore.WriteError("Simulated write failure") diff --git a/tests/_sync/test_http11.py b/tests/_sync/test_http11.py index f5e6fe6e3..1f3546347 100644 --- a/tests/_sync/test_http11.py +++ b/tests/_sync/test_http11.py @@ -398,7 +398,9 @@ def __init__(self, buffer: typing.List[bytes], fail_after_writes: int) -> None: self._writes = 0 self._fail_after_writes = fail_after_writes - def write(self, buffer: bytes, timeout: typing.Optional[float] = None) -> None: + def write( + self, buffer: bytes, timeout: typing.Optional[float] = None + ) -> None: self._writes += 1 if self._writes > self._fail_after_writes: raise httpcore.WriteError("Simulated write failure")