Last reviewed: November 4, 2026 (Asia/Shanghai).
Direct answer
Keep CometAPI HTTPX response hooks limited to approved response metadata on streaming routes. A hook that calls await response.aread() reads the body before the response reaches your application. Even with client.stream(), that hook can make the consumer wait for the entire generation and move buffering ahead of its byte limits and event parser.
The HTTPX Event Hooks guide
states that response hooks run before HTTPX determines whether to read the body. It explicitly requires read() or aread() when a hook needs body access. That capability changes who consumes the response; it is not a passive inspection of an unread stream.
CometAPI’s Chat Completions reference
documents incremental SSE delivery with stream: true and a terminal data: [DONE] marker. Preserve that incremental path by observing headers in the hook and recording body progress inside the existing stream consumer. Verify that the consumer can receive an early chunk while a later chunk is deliberately withheld.
Who this is for
This guide is for Python gateway maintainers adding logging, tracing, or error reporting to an HTTPX client that serves CometAPI streams. Use it when a stream starts arriving all at once after an observability change, or when application-level body limits appear correct but memory grows before the reader starts.
The reproduction requires Python 3.11 or later and HTTPX. It uses an in-memory transport and synthetic SSE fragments, with no API key or provider request. It tests consumption order; your deployed transport, parser, and downstream delivery still need their own acceptance checks.
Key takeaways
- A response hook runs before the streaming caller receives its response.
await response.aread()in that hook can buffer the body before the caller’s first read.- Accessing an unread
.content,.text, or.json()is not a substitute for a deliberate body policy. - Record response headers, first accepted event, protocol completion, and cleanup as separate milestones.
- Test delayed chunks through the complete configured client, including every hook and instrumentation wrapper.
Contract details to verify
Find the first consumer of the body
Inventory hooks at client construction and inspect later assignments to client.event_hooks. HTTPX supports multiple hooks and allows them to modify request and response objects. A harmless observer does not protect the route if another installed hook reads the body first. Include tracing integrations, SDK wrappers, and error-reporting callbacks in the audit.
Look for aread(), read(), body iteration, and body serializers. A convenience change such as reading the response before logging the first 200 characters still reads the full body. Truncating the log entry afterward does not bound the earlier read. A hook that partially iterates the live stream is another consumption path; it needs an explicit ownership contract to avoid taking bytes away from the parser.
The HTTPX async guide
documents aiter_bytes(), aiter_text(), and aiter_raw() for incremental reads, and aread() for reading a response within a stream block. Put the body policy in the component that owns those reads. The site’s response-size guide
explains why a limit after buffering arrives too late for complete JSON responses. The same ordering issue matters before an SSE reader takes control.
Use a hook that records only approved metadata
Save this as stream_observer.py:
import logging
import httpx
log = logging.getLogger("gateway.cometapi")
async def response_metadata(response: httpx.Response) -> None:
log.info("cometapi_response_headers status=%d", response.status_code)
Register it at construction with event_hooks={"response": [response_metadata]} on your reusable httpx.AsyncClient. HTTPX requires asynchronous hook functions for AsyncClient. Keep the existing client lifetime, transport configuration, deadlines, and admission limits; replacing the observer does not require a new client for every request.
The fixed message and integer status avoid copying a body, complete URL, headers, credential, or exception object into the event. Add an opaque operation reference or approved route label only through your reviewed telemetry schema. The site’s telemetry redaction guide covers that broader field policy.
A logging handler can still block or raise, and an asynchronous hook can still await a slow exporter. Give telemetry its own bounded delivery policy. For optional operational telemetry, a full export queue should have a documented drop or aggregation behavior; it should not create an unlimited wait inside every provider request. Do not suppress exceptions from unrelated policy-enforcement hooks while changing the observer.
Name each timing milestone accurately
| Milestone | Where to observe it | What it establishes |
|---|---|---|
| Response headers observed | Metadata-only response hook | Status and headers are available locally |
| First body chunk received | Owning body iterator | The local consumer has begun receiving bytes |
| First accepted SSE event | Existing bounded SSE parser | A complete event passed the parser’s checks |
| Protocol completion | Parser’s terminal-event handling | The documented stream terminator was accepted |
| Local response cleanup | Exit from the response ownership scope | Local closure finished, when the close operation returns |
A byte chunk can split an SSE event. Do not label its arrival “first token” unless the parser has actually accepted a text delta. Likewise, a status 200 event does not establish that generation completed or that a user received the result.
Measure elapsed intervals with your application’s monotonic clock around the relevant owning scope. In the tested HTTPX version, response.elapsed was unavailable before the streamed response was read or closed; forcing a body read to populate it would introduce the defect being measured. A response hook is therefore the wrong place to demand a final completion-duration value.
HTTPX’s Logging guide
distinguishes the high-level httpx logger from network-level httpcore output and warns that debug formatting can change between versions. Those diagnostics can help investigate ordering, but a rendered debug line is a poor foundation for a permanent completion metric. Review the data emitted by enabled library loggers separately from this small application hook.
Happy-path operator workflow
Choose one streaming route and list every component between HTTP response acquisition and the SSE parser. Replace body-reading observability hooks with metadata observers. Keep status acceptance, content-type checks, byte and event limits, parser validation, downstream backpressure, and response ownership in their existing processing scope.
Run the controlled check below with the same hook registration used by the application. Require zero body reads when the streaming consumer enters, then require the first chunk to reach that consumer while the final chunk remains paused. Finally, release the final chunk and verify the complete fixture bytes and response closure.
Repeat with the production instrumentation configuration in staging. Observe first accepted event, accepted terminal marker, downstream completion, and cleanup separately. Compare the same route before and after the observer change. Avoid attributing all latency to the provider when local instrumentation is still inside the measured interval.
Error-path operator workflow
Keep error responses inside the same body policy. A hook that reads only statuses 400 and above can still buffer a large error body before the application rejects it. If diagnostics require an error excerpt, read it through a separate bounded path owned by the adapter and apply the approved content policy. A small log excerpt alone does not establish a small read.
Distinguish a hook exception from a provider status failure, a read timeout, and an SSE validation failure. An observer can fail before the caller enters its stream block even though the remote request was already sent. Preserve the operation identity and original attempt outcome; an instrumentation failure does not authorize another generation or prove that no usage accrued.
Exercise cancellation and failed telemetry delivery through the actual wrappers. Verify who closes a response when acquisition raises before returning it, as well as when the consumer fails after entry. The site’s HTTPX response cleanup guide covers the surrounding ownership contract. Local closure, accepted protocol completion, and remote cancellation remain separate outcomes.
Verify consumption order offline
Save this as hook_probe.py beside the observer and run python3 hook_probe.py with HTTPX installed. MockTransport handles every request to the reserved .invalid hostname. The first and final fragments are transport fixtures, not a complete production completion transcript.
import asyncio
import httpx
from stream_observer import response_metadata
FIRST = b'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'
LAST = b"data: [DONE]\n\n"
class PausedBody(httpx.AsyncByteStream):
def __init__(self):
self.started = asyncio.Event()
self.release = asyncio.Event()
self.reads = 0
self.closes = 0
async def __aiter__(self):
self.reads += 1
self.started.set()
yield FIRST
await self.release.wait()
self.reads += 1
yield LAST
async def aclose(self):
self.closes += 1
async def consume_in_hook(response):
await response.aread()
async def trial(eager):
body = PausedBody()
entered = asyncio.Event()
first_seen = asyncio.Event()
entry_reads = []
received = []
async def reply(request):
assert request.url.host == "fixture.invalid"
return httpx.Response(
200, headers={"Content-Type": "text/event-stream"}, stream=body
)
hook = consume_in_hook if eager else response_metadata
async with httpx.AsyncClient(
transport=httpx.MockTransport(reply),
event_hooks={"response": [hook]},
) as client:
async def consume():
async with client.stream(
"POST", "https://fixture.invalid/chat", json={"stream": True}
) as response:
entry_reads.append(body.reads)
entered.set()
async for chunk in response.aiter_bytes():
received.append(chunk)
first_seen.set()
async with asyncio.TaskGroup() as group:
task = group.create_task(consume())
await body.started.wait()
if eager:
assert not entered.is_set()
assert not first_seen.is_set()
else:
await first_seen.wait()
assert entered.is_set() and body.reads == 1
body.release.set()
await task
assert b"".join(received) == FIRST + LAST
assert body.closes == 1
return entry_reads[0]
async def main():
async with asyncio.timeout(5):
eager = await trial(True)
metadata = await trial(False)
assert (eager, metadata) == (2, 0)
print(f"body reads before consumer: consuming={eager}, metadata={metadata}")
print("progress before final chunk and response closure: passed")
asyncio.run(main())
On November 4, 2026 in Asia/Shanghai, a local run with Python 3.12.3 and HTTPX 0.28.1 reported consuming=2, metadata=0 and passed the progress and closure checks. The eager hook consumed both chunks before entering the caller’s stream block. The metadata hook let the consumer receive the first chunk before the final chunk was released. Both paths ultimately returned the same concatenated bytes, so a final-content assertion alone would miss the regression.
Additional local checks found that .content, .text, and .json() raised ResponseNotRead on an unread streamed response. The metadata observer emitted only its fixed status message despite synthetic sensitive markers in the request and response. A deliberately raising response hook propagated its exception, prevented entry to the caller’s block, and closed the fixture stream once without reading it. These observations apply to the stated version and in-memory path.
The five-second watchdog bounds this offline test; it is not a production timeout recommendation. The check does not exercise real sockets, proxy buffering, provider latency, SSE parsing, or downstream delivery. Before rollout, repeat delayed-output and failure checks through those deployed components, including every registered hook. No authenticated CometAPI calls were made for these local observations.
Failure modes
A preview reads everything first. Code calls aread() and then slices the result for logging. The stream has already been buffered. Remove body access from the observer and keep any permitted bounded diagnostics in the adapter.
Only the success hook is reviewed. Error reporting reads a potentially large body on failure. Exercise both successful and rejected status paths with delayed and oversized fixtures.
A later hook is harmless but an earlier hook consumes. The application still sees buffered bytes. Test the whole installed list and the wrappers that add to it.
The test checks only final content. Cached bytes can match perfectly after incremental delivery has disappeared. Assert progress while the later fragment is still withheld.
A telemetry failure becomes automatic failover. Remote work may already have begun. Classify the local observer failure and apply the original operation’s attempt policy.
FAQ
Does client.stream() override body reads inside a hook?
No. HTTPX runs response hooks before deciding whether to read the body for the caller. An explicit aread() in that earlier hook can consume it first.
Can a response hook inspect status without reading the body?
Yes. The metadata observer uses response.status_code without a body read. A separate status-enforcement hook can deliberately raise, but that behavior must remain distinct from optional telemetry.
Can I call .json() on an SSE response to log it?
An SSE stream is a sequence of framed events, rather than one JSON response document. Keep event parsing in the existing bounded parser. In the tested unread-response path, .json() raised ResponseNotRead before any JSON interpretation.
Is a small iterator chunk size a replacement for this check?
No. A later iterator cannot undo a full-body read that already happened inside a hook. Establish the first consumption point before reasoning about later chunk or event limits.
Reader next step
Audit the response hooks on one CometAPI streaming client, remove observability-driven body reads, and run the delayed-fragment probe through its configured client. Approve the change when the consumer makes progress before the final fragment, rejected paths preserve cleanup, and operational events stay within the approved field schema.
Then Start with CometAPI and run a small authorized staging stream with your approved model. Verify actual event delivery and cleanup through the deployed gateway before drawing conclusions about provider latency.
Sources checked
- HTTPX: Event Hooks — fetched November 3 UTC, November 4 in Asia/Shanghai. Supports hook ordering, explicit body reads, multiple and mutable hooks, and asynchronous hook requirements.
- HTTPX: Async Support
— fetched November 3 UTC, November 4 in Asia/Shanghai. Supports incremental response interfaces,
aread(), reusable clients, and context-managed or manual response closure. - HTTPX: Logging
— fetched November 3 UTC, November 4 in Asia/Shanghai. Supports the distinction between
httpxandhttpcorelogging and the caution about version-dependent debug formats. - CometAPI: Create a chat completion
— fetched November 3 UTC, November 4 in Asia/Shanghai. Supports
stream: true, incremental SSE delivery, completion chunks, and the terminaldata: [DONE]marker.
These maintained primary technical references support an evergreen client-instrumentation task. The field policy, timing design, incident decisions, and rollout criteria are engineering recommendations. The local results describe synthetic fixtures on the stated versions; they do not report a provider incident or a production deployment.