When an outbound HTTPX AsyncClient request times out with a PoolTimeout on a responsive local server, the symptom can be surprising: a fast origin is idle, yet the next request stalls. In this scenario we have an explicitly limited AsyncClient (one connection allowed) and a hand-managed streaming response that remains open. The question is whether the timeout is happening during connection acquisition (pool) or while waiting for body data (read). Using a controlled loopback server and a logging ledger of request IDs, we will demonstrate how holding an unclosed stream retains the sole HTTP/1.1 connection. We record every attempted request ID, confirm which requests reached the server, and inspect exception types. The evidence shows that an unfinished streaming response ties up the pool slot until it is closed. This is a client-side connection pool issue, not a server capacity or application slowness problem (for broader context on scaling endpoints, see the API performance and scalability context). Ultimately, we prove that explicitly closing or releasing the response stream restores the client’s connection capacity.
Recognize a local connection-acquisition failure
First, understand HTTPX’s timeout phases. HTTPX documents four timeout types: connect, read, write, and pool. Each is a distinct phase:
Connect timeout (ConnectTimeout) – time to establish TCP/TLS.
Read timeout (ReadTimeout) – waiting for response data.
Write timeout (WriteTimeout) – sending request data.
Pool timeout (PoolTimeout) – waiting for a free connection from the client’s pool.
These phases align with the AsyncClient's request lifecycle. A pool timeout occurs if the client cannot acquire a connection slot before the defined interval. A read timeout occurs while receiving data, which can include response headers or body data. In the controlled read case below, the headers have already arrived and the body remains unfinished. In our one-connection fixture, an unclosed response keeps that sole slot busy. To diagnose, we compare timestamps of attempts, request IDs, and exception types. The decision to make is at the client: has the old response owner released the connection, or is it still held? The broader integration story of request performance is discussed in Refonte's guide on API performance and scalability context.
HTTPX's exception definitions identify the phase: PoolTimeout means the client timed out waiting to acquire a connection, whereas ReadTimeout means it timed out while receiving data. A ReadTimeout alone does not prove that response headers or body bytes have arrived. In our test, we require the PoolTimeout class for the blocked followers and compare their exact request IDs with the origin's arrival ledger. With one controlled origin, unique IDs, and no retries or redirects, the expected exception together with an absent arrival supports connection-acquisition blocking. An absent ledger entry by itself would not establish that diagnosis in an uncontrolled system.
Timeout Type | Phase | Exception |
Connect | Establish socket | ConnectTimeout |
Read | Receive data | ReadTimeout |
Write | Send data | WriteTimeout |
Pool | Acquire slot | PoolTimeout |
The task is to ensure the client correctly releases any owned response object before making the next request. Otherwise the second request will simply wait (and then time out) for available pool capacity, even if the origin is fast.
Pin the environment and define the one-slot boundary
We create an isolated lab environment using Python 3.12.x and pin HTTPX 0.28.1 for reproducibility. HTTPX 0.28.1 was released on December 6, 2024. The fixture checks the declared Python and HTTPX versions and records the actual Python, HTTPX, httpcore, and AnyIO versions in its JSON report. Record the installed dependencies rather than assuming that an HTTPX version specifies one exact dependency environment. There are no inherited proxy settings: we set trust_env=False to ignore environment configuration. Clients are constructed with http2=False to use HTTP/1.1 and follow_redirects=False. We use Limits(max_connections=1, max_keepalive_connections=1) so that only one connection is allowed and at most one idle connection can be retained. The fixed requests are GET requests to 127.0.0.1 on an ephemeral port. No retries or back-off are configured, so each registered attempt represents one request attempt.
Timeout values are explicit test parameters, not production suggestions. The baseline is HTTPX Timeout(connect=2.0, read=1.0, write=2.0, pool=0.5). For the incorrect-repair case, we change only the read timeout to 3.0 seconds, using Timeout(connect=2.0, read=3.0, write=2.0, pool=0.5). A five-second asyncio watchdog bounds each client operation, barrier wait, managed-stream scope, and close operation. A separate 30-second bound covers the scenario, with a ten-second cleanup bound afterward and, if cleanup fails, one additional five-second wait for cancelled handlers. Watchdog failures are recorded distinctly and are not expected in a passing run. HTTPX's phase timeouts remain separate from the test harness's bounds.
Separate library timeouts from the harness watchdog
The HTTPX timeouts above are independent of the Python-level watchdog timers. A five-second asyncio.wait_for() around an operation can expire if the operation does not complete within the harness bound. The fixture records that separately as a HarnessWatchdog failure. A raised httpx.PoolTimeout or httpx.ReadTimeout comes from HTTPX's timeout handling and retains its own exception class. The 30-second scenario bound is also a harness limit. These distinctions prevent a stalled test harness from being reported as the connection-pool failure we intend to study.
Build an origin that keeps one body deliberately unfinished
We implement a real local HTTP/1.1 server using asyncio.start_server. Each incoming connection is handled by a coroutine that reads HTTP headers up to \r\n\r\n. We accept only GET requests on /fast and /hold, each with a planned X-Lab-Request-ID header. Header names are handled case-insensitively and their values are stripped before validation. After parsing and validating a request, the server logs its request ID, path, and monotonic timestamp in the arrival ledger. The response echoes that ID in an X-Lab-Request-ID header so the client can match it. A malformed request, unexpected path, or unplanned identity is a fixture failure. We track active handler tasks and their StreamWriters for bounded cleanup.
Keep the hold route open without simulating truncation
The /fast route writes a valid HTTP/1.1 200 response with Content-Length: 3, a body of b"ok\n", and Connection: close. It drains and closes the writer, completing the response normally. The /hold route writes headers declaring Content-Length: 8 and Connection: close, then writes only the byte b"H" and calls writer.drain(). It leaves seven bytes unsent and keeps the connection open. After writing the prefix, the handler sets an ID-specific asyncio event and waits for client EOF through reader.read(1). Its cleanup closes the local writer and awaits closure with a bound. This deliberately unfinished body occupies the connection slot. Immediately closing the origin connection would instead introduce an incomplete-body protocol error. Using Connection: close consistently also avoids keep-alive request parsing in this small server. Before sending the follower, the driver waits both for the streaming Response object and for the server's barrier event. The barrier establishes fixture ordering; it does not claim the client has consumed the body prefix. No arbitrary sleeps are required.
Register the evidence contract before sending requests
Before sending requests, we construct an independent manifest and report template. A UUID gives each run and its JSON report a distinct identity. The report starts with a failure verdict, version fields, and empty evidence collections. Each request attempt is registered under its full planned ID before awaiting the request. The ten literal wire IDs are P01-fast, P02-hold, P03-blocked, P04-read-longer, P05-repaired, P06-hold, P06-after, P07-hold, P07-after, and P08-read. These identities are authored independently of observed traffic, so a missing attempt cannot disappear from the expected contract.
The contract defines eight cases and ten HTTP request attempts. P06 and P07 each contain a held response and a distinct follow-up request. We expect exactly eight unique request IDs to reach the origin; P03-blocked and P04-read-longer must be absent. Reading the body in P08 does not create an additional HTTP request. The following table summarizes each case:
Case | Controlled action | Independent expected outcome |
P01 | Complete /fast baseline | Matching ID, 200 and b"ok\n"; one arrival |
P02 | Keep a manual /hold response open | Returned headers and origin barrier; one arrival |
P03 | Send /fast through the occupied client | PoolTimeout; P03-blocked absent |
P04 | Increase only read timeout; repeat follower | PoolTimeout; P04-read-longer absent |
P05 | Await response closure; send /fast again | P05-repaired arrives and succeeds |
P06 | Leave managed /hold normally; send /fast | Both IDs arrive; subsequent request succeeds |
P07 | Raise a consumer exception inside managed /hold; then send /fast | Expected consumer exception retained; both IDs arrive and probe succeeds |
P08 | Read the held body using a free pool | ReadTimeout; P08-read present |
The report records each origin arrival during the run and compares the final ledger against the independent manifest. We require the eight permitted wire IDs exactly once each, with no unexpected identities. P03-blocked and P04-read-longer must be absent. Every planned attempt and case must also have its expected result, including PoolTimeout for both blocked followers and ReadTimeout for P08-read. Durations support diagnosis, while the verdict depends on exact identities, exception classes, response checks, and clean teardown. Any missing case, unexpected arrival, or mismatched outcome fails the run.
Save the following Python example as fixture.py.
import asynciofrom collections import Counterfrom importlib.metadata import versionimport jsonfrom pathlib import Pathimport platformimport sysimport timefrom uuid import uuid4import httpxclass FixtureConsumerError(Exception): passclass HarnessWatchdog(Exception): pass# Authored before execution; these are ten different wire request IDs.MANIFEST = [ ("P01-fast", "/fast", True, "200 OK"), ("P02-hold", "/hold", True, "held"), ("P03-blocked", "/fast", False, "PoolTimeout"), ("P04-read-longer", "/fast", False, "PoolTimeout"), ("P05-repaired", "/fast", True, "200 OK"), ("P06-hold", "/hold", True, "stream closed"), ("P06-after", "/fast", True, "200 OK"), ("P07-hold", "/hold", True, "FixtureConsumerError"), ("P07-after", "/fast", True, "200 OK"), ("P08-read", "/hold", True, "ReadTimeout"),]BASE = httpx.Timeout(connect=2.0, read=1.0, write=2.0, pool=0.5)LONG_READ = httpx.Timeout(connect=2.0, read=3.0, write=2.0, pool=0.5)async def run_fixture(): report = { "run_id": uuid4().hex, "result": "FAIL", "versions": {}, "manifest": MANIFEST, "attempts": [], "origin_arrivals": [], "cases": [], "operations": [], "errors": [], "server_errors": [], "cleanup": {"errors": []}, "settings": {"max_connections": 1, "max_keepalive_connections": 1, "http2": False, "trust_env": False, "follow_redirects": False, "retries": 0, "base_timeout": BASE.as_dict(), "long_read_timeout": LONG_READ.as_dict(), "operation_watchdog_s": 5, "scenario_watchdog_s": 30}, } report_path = Path(f"report_{report['run_id']}.json") routes = {reqid: path for reqid, path, , in MANIFEST} barriers = {reqid: asyncio.Event() for reqid, path, , in MANIFEST if path == "/hold"} clients, responses, writers, tasks = [], [], set(), set() server = None base_url = "" def error(label, exc): return {"operation": label, "type": type(exc).__name__, "message": str(exc)} async def watch(label, awaitable): start, outcome = time.monotonic(), "OK" try: return await asyncio.wait_for(awaitable, timeout=5) except TimeoutError as exc: outcome = "HarnessWatchdog" raise HarnessWatchdog(label) from exc except BaseException as exc: outcome = type(exc).__name__ raise finally: report["operations"].append({"operation": label, "elapsed_s": time.monotonic() - start, "outcome": outcome}) async def quiet_close(label, awaitable): try: await watch(label, awaitable) except Exception as exc: report["cleanup"]["errors"].append(error(label, exc)) async def handle(reader, writer): try: data = await watch("origin headers", reader.readuntil(b"\r\n\r\n")) lines = data.decode("ascii").split("\r\n") method, path, protocol = lines[0].split() headers = {} for line in lines[1:]: if line: key, value = line.split(":", 1) key = key.strip().lower() assert key not in headers, "Duplicate request header" headers[key] = value.strip() reqid = headers.get("x-lab-request-id") assert method == "GET" and protocol == "HTTP/1.1" assert reqid in routes and routes[reqid] == path report["origin_arrivals"].append({"id": reqid, "path": path, "monotonic_s": time.monotonic()}) body, length = (b"ok\n", 3) if path == "/fast" else (b"H", 8) head = ("HTTP/1.1 200 OK\r\n" f"Content-Length: {length}\r\n" "Connection: close\r\n" f"X-Lab-Request-ID: {reqid}\r\n\r\n") writer.write(head.encode("ascii") + body) await watch(f"{reqid} origin drain", writer.drain()) if path == "/hold": barriers[reqid].set() # Reading EOF detects peer closure; wait_closed alone does not. extra = await watch(f"{reqid} peer EOF", reader.read(1)) assert extra == b"", "Unexpected data after the fixed GET" except asyncio.CancelledError: raise except Exception as exc: report["server_errors"].append(error("origin handler", exc)) finally: writer.close() await quiet_close("origin writer close", writer.wait_closed()) writers.discard(writer) def connected(reader, writer): writers.add(writer) task = asyncio.create_task(handle(reader, writer)) tasks.add(task) task.add_done_callback(tasks.discard) def new_client(): client = httpx.AsyncClient( http2=False, trust_env=False, follow_redirects=False, limits=httpx.Limits(max_connections=1, max_keepalive_connections=1), timeout=BASE, ) clients.append(client) return client def begin(reqid): assert reqid not in [row["id"] for row in report["attempts"]] row = {"id": reqid, "path": routes[reqid], "monotonic_s": time.monotonic(), "result": "unfinished"} report["attempts"].append(row) return row def check_headers(response, reqid): assert response.status_code == 200 assert response.http_version == "HTTP/1.1" assert response.headers["x-lab-request-id"] == reqid expected = "8" if routes[reqid] == "/hold" else "3" assert response.headers["content-length"] == expected async def request(client, reqid, stream=False, timeout=BASE): row = begin(reqid) try: # send() has no timeout parameter: configure the built request. req = client.build_request("GET", base_url + routes[reqid], headers={"X-Lab-Request-ID": reqid}, timeout=timeout) response = await watch(reqid, client.send(req, stream=stream)) responses.append(response) check_headers(response, reqid) if stream: await watch(f"{reqid} barrier", barriers[reqid].wait()) assert not response.is_closed row["result"] = "held" else: assert response.content == b"ok\n" row["result"] = "200 OK" return response except Exception as exc: row["result"] = type(exc).__name__ row["message"] = str(exc) raise async def managed(client, reqid, action="normal"): row = begin(reqid) try: async with client.stream("GET", base_url + "/hold", headers={"X-Lab-Request-ID": reqid}, timeout=BASE) as response: responses.append(response) check_headers(response, reqid) await watch(f"{reqid} barrier", barriers[reqid].wait()) assert not response.is_closed if action == "consumer error": raise FixtureConsumerError("Simulated error in consumer") if action == "read": await watch(f"{reqid} body", response.aread()) raise AssertionError("Expected a body ReadTimeout") assert response.is_closed row["result"] = "stream closed" except Exception as exc: row["result"] = type(exc).__name__ row["message"] = str(exc) raise async def expect(exception_type, awaitable): try: await awaitable except exception_type as exc: assert type(exc) is exception_type return exc raise AssertionError(f"Expected {exception_type.__name__}") def passed(case): report["cases"].append({"case": case, "result": "PASS"}) async def scenario(): nonlocal server, base_url report["versions"] = {"python": platform.python_version(), "python_build": sys.version, **{name: version(name) for name in ("httpx", "httpcore", "anyio")}} assert sys.version_info[:2] == (3, 12), "Use Python 3.12.x" assert version("httpx") == "0.28.1", "Use HTTPX 0.28.1" server = await watch("start origin", asyncio.start_server(connected, "127.0.0.1", 0)) port = server.sockets[0].getsockname()[1] base_url = f"http://127.0.0.1:{port}" report["settings"]["origin"] = base_url client = new_client() await request(client, "P01-fast") passed("P01") manual = await request(client, "P02-hold", stream=True) passed("P02") await expect(httpx.PoolTimeout, request(client, "P03-blocked")) passed("P03") await expect(httpx.PoolTimeout, request(client, "P04-read-longer", timeout=LONG_READ)) passed("P04") assert not manual.is_closed await watch("P05 close manual response", manual.aclose()) assert manual.is_closed await request(client, "P05-repaired") passed("P05") await watch("P05 client close", client.aclose()) client = new_client() await watch("P06 managed scope", managed(client, "P06-hold")) await request(client, "P06-after") passed("P06") await watch("P06 client close", client.aclose()) client = new_client() exc = await expect(FixtureConsumerError, watch("P07 managed scope", managed(client, "P07-hold", "consumer error"))) assert str(exc) == "Simulated error in consumer" await request(client, "P07-after") passed("P07") await watch("P07 client close", client.aclose()) client = new_client() await expect(httpx.ReadTimeout, watch("P08 managed scope", managed(client, "P08-read", "read"))) passed("P08") await watch("P08 client close", client.aclose()) async def cleanup(): for response in responses: if not response.is_closed: await quiet_close("response cleanup", response.aclose()) for client in clients: if not client.is_closed: await quiet_close("client cleanup", client.aclose()) if server is not None: server.close() await quiet_close("listener cleanup", server.wait_closed()) for writer in tuple(writers): writer.close() if tasks: await quiet_close("handler cleanup", asyncio.gather(*tuple(tasks), return_exceptions=True)) report["cleanup"]["open_responses"] = sum(not r.is_closed for r in responses) report["cleanup"]["open_clients"] = sum(not c.is_closed for c in clients) report["cleanup"]["writers"] = len(writers) report["cleanup"]["active_handlers"] = sum(not t.done() for t in tasks) try: async with asyncio.timeout(30): await scenario() except TimeoutError as exc: report["errors"].append(error("scenario watchdog", exc)) except Exception as exc: report["errors"].append(error("scenario", exc)) finally: try: async with asyncio.timeout(10): await cleanup() except Exception as exc: report["cleanup"]["errors"].append(error("cleanup watchdog", exc)) if server is not None: server.close() for writer in tuple(writers): writer.close() for task in tuple(tasks): task.cancel() if tasks: await quiet_close("cancelled handler cleanup", asyncio.gather(*tuple(tasks), return_exceptions=True)) try: expected_ids = [row[0] for row in MANIFEST] assert [row["id"] for row in report["attempts"]] == expected_ids assert [row["result"] for row in report["attempts"]] == [ row[3] for row in MANIFEST] assert Counter(row["id"] for row in report["origin_arrivals"]) == Counter( reqid for reqid, , arrived, in MANIFEST if arrived) assert report["cases"] == [ {"case": f"P{i:02}", "result": "PASS"} for i in range(1, 9)] assert not report["errors"] and not report["server_errors"] assert not report["cleanup"]["errors"] fields = ("open_responses", "open_clients", "writers", "active_handlers") for field in fields: assert report["cleanup"][field] == 0, field report["result"] = "PASS" except Exception as exc: report["errors"].append(error("final reconciliation", exc)) report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") print(f"{report['result']}: {report_path}") return 0 if report["result"] == "PASS" else 1if name == "__main__": raise SystemExit(asyncio.run(run_fixture()))# Run in the isolated Python 3.12 environment
python3 -m pip install "httpx==0.28.1"
python3 fixture.pyEstablish a successful baseline and retain the manual response
We first verify that /fast works through the one-slot AsyncClient. P01-fast must return status 200, the matching echoed ID, and b"ok\n". The origin must record P01-fast exactly once. Next, on the same client, we initiate P02-hold with a manual streaming send: await client.send(request, stream=True). The request is built with the explicit baseline timeout settings; AsyncClient.send() itself does not take a timeout argument. We do not read the body yet. We check status 200 and the echoed P02-hold identity, then wait for the server's barrier event. The declared eight-byte body is still unfinished by design. We retain the Response as manual without calling aread() or aclose(). The response owner must eventually release this capacity. Unlike a typical content check, such as Python download artifact validation, this incomplete body is intentionally used to hold the connection slot.
Reproduce PoolTimeout without an origin arrival
With P02-hold still occupying the only connection, we attempt P03-blocked: a second GET /fast through the same AsyncClient. This request cannot acquire a connection within the configured pool interval. We require the specific exception httpx.PoolTimeout and record its class. Any other exception, or an unexpected response, fails the case. We also require that P03-blocked is absent from the controlled origin's arrival ledger. Together with the expected exception and the fixture's restrictions on requests, that absence supports a failure during connection acquisition. We retain the concrete PoolTimeout name rather than recording a generic timeout. After recording the case, we continue the investigation and later reconcile the complete ledger after shutdown so that unexpected late arrivals cannot be overlooked.
Preserve the failure phase instead of a generic timeout label
PoolTimeout and ReadTimeout are both subclasses of HTTPX's TimeoutException, but they identify different phases. We catch PoolTimeout specifically and preserve the exception class, attempt ID, and elapsed time. We do not catch every TimeoutException and relabel it. In this controlled fixture, retries and redirects are disabled and one process owns the origin. The expected PoolTimeout and the absent follower identity therefore support a connection-acquisition diagnosis. After the requests and bounded shutdown complete, the final ledger check also rejects any unexpected P03-blocked arrival.
Test why a longer read timeout does not release capacity
Next, we adjust only the read timeout for the follower on the same occupied client. P04-read-longer uses Timeout(connect=2.0, read=3.0, write=2.0, pool=0.5), leaving the other three phase limits unchanged. We again require PoolTimeout and no P04-read-longer arrival. This control shows that lengthening the wait for received data does not release the occupied pool slot. A larger read timeout does not instruct HTTPX to close the retained response or allow an additional connection. It changes the read-phase limit, which this blocked follower has not reached. Because the follower still cannot acquire capacity, the expected outcome is unchanged. The connection limit remains one and the retained response still requires an explicit owner to close it. Subsequent requests use the baseline configuration.
Close the response owner and prove capacity recovers
We now await manual.aclose() for P02-hold. This closes the unfinished response and releases its occupied capacity. We then send P05-repaired, a GET /fast on the same AsyncClient. It must return status 200, the echoed P05-repaired identity, and b"ok\n". The origin must record that ID exactly once. Closing an unfinished response does not promise reuse of its TCP connection; the fixture uses Connection: close deliberately. The successful follow-up demonstrates that the existing client's pool can make progress after the owner releases the stream. Cleanup remains protected by finally blocks so that an earlier unexpected outcome still triggers closure of retained responses and clients. A passing result requires clean teardown as well as the successful repair probe.
Exercise normal and exceptional managed-stream exits
To cover context-manager usage, we repeat similar tests with async with client.stream(...):
P06: We open a separate one-slot AsyncClient and enter async with client.stream(...) for GET /hold using P06-hold. Inside the block, we check the response and wait for the server barrier, then exit normally without consuming the unfinished body. The context exit closes the response. We send P06-after to /fast through that same client and require status 200, the matching echoed ID, and b"ok\n". Both P06-hold and P06-after must appear exactly once in the origin ledger. This demonstrates that normal exit from the managed-stream block releases capacity before the follow-up request.
Preserve a consumer error while checking post-exit progress
P07: We use another client with the same one-connection pool limit and enter a managed /hold stream using P07-hold. After checking the response and reaching the barrier, we raise FixtureConsumerError with a fixed message. We catch that exception outside the context and retain its exact class and message in the result. We then send P07-after to /fast through the same client and require a matching identity, status 200, and b"ok\n". Both wire IDs must appear exactly once in the origin ledger. Unexpected exceptions are failures. HTTPX's managed-stream guidance explains that leaving the stream block closes its response. This case checks that behavior during a consumer error and verifies subsequent progress. The application code still owns the response lifetime; the context manager makes its closure explicit even when consumption raises an exception.
Contrast a genuine read timeout after the request arrives
Finally, we test a request that reaches the server but cannot finish reading its body. Using a free one-slot client, we open a managed /hold stream with P08-read, check status 200 and its echoed identity, and wait for the barrier. We then await response.aread(). The origin has sent only one of the declared eight body bytes and keeps the connection open, so the required outcome is httpx.ReadTimeout. P08-read must appear exactly once in the arrival ledger. The body read belongs to that existing response and is not a second request attempt. The exception unwinds the stream context, which closes the response; we then record the exception and close the client.
Both P03-blocked and P08-read end in timeouts, but their evidence differs. P03-blocked raises PoolTimeout and has no origin arrival. P08-read arrives, returns checked headers, and raises ReadTimeout while the client waits for the unfinished body. This distinction parallels validating a health-check response contract: receiving headers and validating the required content are separate concerns. Here, the controlled observations establish which phase failed. The harness watchdog remains active around the operation, but the expected P08 exception comes from the HTTPX read-phase timeout of one second. A HarnessWatchdog outcome would fail this case instead of being accepted as ReadTimeout.
Reconcile the ledger and enforce a release decision
After the cases, we close remaining responses and clients, close the listener, and finish bounded handler and writer cleanup. We then reconcile the arrival ledger. It must contain exactly P01-fast, P02-hold, P05-repaired, P06-hold, P06-after, P07-hold, P07-after, and P08-read, each once. P03-blocked and P04-read-longer must be absent. The attempt ledger must contain all ten planned wire IDs exactly once. Results must show PoolTimeout for both blocked followers, ReadTimeout for P08-read, the expected response checks for the successful requests, and the preserved consumer exception in P07. Any missing identity, wrong outcome, or cleanup error keeps the verdict at FAIL. The report records versions, settings, attempts, arrivals, results, durations, and cleanup status. Final report writing occurs in a finally path so that an ordinary fixture failure still produces evidence; abrupt process termination or a filesystem failure can prevent that write.
Require every planned case and every expected identity
The final criteria enforce completeness across all eight cases and all ten request attempts. Each planned origin arrival must appear exactly once, and no extra identity is allowed. Every result must retain its verified success outcome or exact exception class. A missing case, unexpected handler exception, or cleanup error fails the run. FixtureConsumerError is expected only in the deliberately exceptional P07 consumer path. Only after the scenario, evidence reconciliation, and teardown checks succeed does the unique JSON report record result as PASS. Otherwise it retains FAIL and the available error information. The identity checks, phase-specific exceptions, and post-close successes jointly support the ownership repair demonstrated here.
Apply the ownership repair at the integration boundary
The practical takeaway is how to design the client-side code. Essentially, whoever acquires a streaming response must also close it. In code, this means using async with client.stream(...) to scope the response lifetime, or if using manual mode, wrapping await response.aread() / other processing in a try/finally that calls await response.aclose(). In both cases, the client code signals explicitly when a response is fully dealt with. We start the shared AsyncClient before the first request and finish after the last; do not implicitly rely on garbage collection or leaving a loop.
A timeout decision should follow the evidence. If the expected PoolTimeout occurs while the client still owns an unfinished response, repair that response's lifetime and verify progress through the same client. If ReadTimeout occurs after the controlled request has arrived, investigate the receive phase and the required content. Other exceptions or mismatched observations require their own analysis; a ConnectTimeout, for example, points to connection establishment. In this GET-only fixture, the follow-up probe is sent after closure is awaited. The manual-streaming guidance requires eventual response closure, and the exception definitions help preserve the failure phase. As reliable third-party API integrations explains, explicit expectations and cleanup help make integration behavior easier to review. This experiment does not establish that retrying an arbitrary production request is safe.
Assign ownership, rollback conditions and evidence limits
In operational terms, we assign roles: the outbound client (or its wrapper) owns closing the stream. The test maintainer owns the manifest of IDs and the bounded origin server. The service owner is in the loop to ensure that an “open stream fix” is the right path before a production change. The report documents versions, client settings, and the outcomes of the ownership checks. Passing the controlled lab checks shows the “close-the-stream” path works; it doesn’t attest to overall throughput or correctness under load.
If an unexpected exception occurs, cleanup fails, or progress resumes only after replacing the client, hold the ownership repair for further investigation. If the proposed change is already being evaluated in a deployment, revert it to the known configuration while preserving the evidence. Investigate whether the response's aclose() was actually awaited, whether control flow skipped cleanup, or whether an exception handler concealed another failure. This test uses only GET requests and does not establish write idempotency or retry safety. It also does not promise reuse of the original TCP connection after an unfinished body; Connection: close is deliberate. The supported conclusion is bounded: in this one-slot fixture, closing the retained stream releases enough capacity for the checked follow-up request through the same client.
Build stronger API testing and error-handling habits
In summary, the pattern is: log every request ID, classify the failure phase (pool vs read), and then verify success after cleanup. This method gives concrete evidence for the correct fix. It’s a disciplined approach to client lifecycle management. (Broadly, such rigor belongs in comprehensive API testing and error handling practices.)
For those interested in strengthening these fundamentals, Refonte Learning’s APIs Developer Fundamentals program covers practical API documentation, testing, and error-handling skills. This 3-month course (10–12 hours per week) includes hands-on projects on REST, GraphQL, integration, documentation & testing, and logging. Students who complete the program receive a Training Certificate and, upon qualifying, a Certificate of Internship. While not required to run this lab, such structured learning can reinforce the best practices demonstrated here.
By assigning each response an owner and verifying progress after cleanup, engineers can diagnose this source of PoolTimeout stalls. The controlled evidence shows why closing the unfinished stream restores available connection capacity: the same one-slot client can complete its next checked request. This result supports the response-ownership repair for the demonstrated failure. It does not measure throughput, establish behavior under load, or guarantee that every pool timeout has the same cause.
