Repository navigation
What's Changed
- fix!: errors and redaction (pickling, query masking, DecodeError message, read-only STATUS_TO_EXCEPTION) by @lesnik512 in #166
- fix: decoder cache keys, msgspec field claims, unread middleware responses by @lesnik512 in #168
- fix: circuit breaker ignores straggler outcomes, unwraps budget exhaustion, rate-limits circuit.rejected by @lesnik512 in #167
- fix!: consistent client error paths (timeout=None, RemoteProtocolError, invalid URL, closed client, streaming bodies, async middleware) by @lesnik512 in #169
- fix!: validate retry and budget config, case-insensitive retry_methods, Retry-After give-up event by @lesnik512 in #177
- docs: put the circuit breaker before AsyncTimeout; document rate-mode and event-loop behaviour by @lesnik512 in #178
- fix: capped responses keep elapsed, next_request and default_encoding; capped redirect and auth hops reuse their connection by @lesnik512 in #179
- perf: build the StatusError message on first use instead of at construction by @lesnik512 in #180
- perf: count RetryBudget and the rate-mode window in time buckets by @lesnik512 in #181
- perf: cut per-request overhead in the client hot path by @lesnik512 in #182
- perf: cheaper hot paths in the circuit breakers, AsyncBulkhead and the event-loop guard by @lesnik512 in #183
- refactor: declare per-request options once as TypedDicts in client.py by @lesnik512 in #186
- test: synchronize bulkhead capacity tests on events instead of sleeps by @lesnik512 in #185
- test: synchronize stream cancellation test on an event instead of a sleep by @lesnik512 in #188
- fix: map InvalidURL in build_request; clear TypeError for sync-stream middleware responses by @lesnik512 in #189
- fix: drop None attributes from OTel span events by @lesnik512 in #190
- fix: bound the decoder adapter caches by @lesnik512 in #191
- perf: count capped bytes on the stream for unencoded bodies by @lesnik512 in #192
- (and the CI, docs-demo and tooling PRs #158 to #165; GitHub's "Generate release notes" lists them)
Breaking changes
timeout=None means no timeout. Client(timeout=None) and AsyncClient(timeout=None) used to be treated as "not set" and kept httpx2's 5 second default. They now disable the timeout, as in httpx2. To keep the default, leave timeout out. Passing timeout=None together with httpx2_client= now raises the same TypeError as any other option combined with a client you built yourself. (#169)
STATUS_TO_EXCEPTION is read-only. It is now a MappingProxyType, so assigning to it raises TypeError. To raise your own class for a status, catch the httpware error in a middleware (an on_error handler, for example) and raise yours from it. (#166)
Retry and budget config is validated at construction. Retry, AsyncRetry and RetryBudget raise ValueError for values that used to crash or misbehave at request time:
base_delaymust be>= 0(infis still accepted).max_delaymust be finite and>= 0. Replacemax_delay=float("inf")with a large finite number.RetryBudget.ttlmust be finite and> 0.min_retries_per_secandpercent_can_retrymust be finite and>= 0. To switch the budget off in practice, pass a largemin_retries_per_sec.
NaN is rejected everywhere. If construction now fails, the old value was already broken once a retry happened. (#177)
Behaviour changes
Errors and observability:
- Exception messages, reprs, log records and span events show every query value as
REDACTED, keeping the keys. Log queries or alerts that matched on query values need updating. The raw URL is still onexc.request.url. (#166) str(DecodeError)readsfailed to decode response into <model>: <ErrorType> (n errors)and no longer includes the decoder's message, which could echo body content. The original error is onexc.original. (#166)- Notes and attributes added to any httpware exception survive pickle and copy. (#166)
httpx2.RemoteProtocolError("server disconnected") now raisesNetworkError, soRetryretries it and circuit breakers count it. (#169)- An invalid URL given to a verb method or
request()raisesTransportErrorinstead ofhttpx2.InvalidURL.stream()on a closed client raisesTransportErrorinstead ofRuntimeError. (#169) Client(middleware=[...])raisesTypeErrorfor async middleware instead of silently sending nothing. (#169)build_requestmaps an invalid URL toTransportErrortoo, like every other method. (#189)- A middleware response whose body iterator is the wrong kind for the client (a sync iterator on
AsyncClient, an async one onClient) raisesTypeErrorwhen decoded. Before,AsyncClientraised a bareRuntimeErrorfrom httpx2 andClientraised aDecodeErrorthat blamed the decoder. (#189) - OpenTelemetry span events leave out attributes whose value is
None(such aslast_statuswhen the last attempt raised), so SDKs before 1.45 no longer log a warning on each give-up. On SDK 1.45+, the key is now missing instead ofNone. Log records keep theNonevalues. (#190) - The
RuntimeErrorfor an async bulkhead or breaker used from a second event loop has a new message that says to create them once per loop. Code matching the old text needs updating. (#178)
Retry:
retry_methodsmatches regardless of case, so{"post"}now retries POST. (#177)- Giving up because
Retry-Afterexceedsmax_delayemitsretry.giving_up. Everyretry.giving_upevent now has areasonattribute:max_attemptsorretry_after_exceeds_max_delay. (#177) - A
Retry-Aftertoo large for a float makes the retry give up instead of retrying immediately. (#177) - Requests passed to
send()orsend_with_response()with a generator, iterator or file body are no longer retried, as with the verb methods. (#169) RetryBudgetcounts in steps ofttl / 10: a deposit or withdrawal stops counting between0.9 * ttlandttlafter it was made. With the defaultttl=10.0, a request can leave the budget up to a second early. Memory no longer grows with traffic. (#181)
Circuit breaker:
- A request that was in flight when its circuit changed state no longer affects the circuit. Before, a late result could reopen a half-open circuit, close it without a successful probe, or let two probes run at once. (#167)
- A
RetryBudgetExhaustedErrorcounts against the breaker according to itslast_exception, so a breaker outside a starved retry now opens on repeated 503s. (#167) circuit.rejectedfires at most once per second per circuit, and always for the first rejection of an outage, with a newsuppressedcount.circuit.closedhas a newrejectedattribute with the outage's total. To count rejections from logs, sum1 + suppressedor readrejected. (#167)- Breaker events are emitted after the sync lock is released, so a logging handler may send requests through the same breaker without deadlocking. (#167)
- The
keyfunction ofKeyedCircuitBreakerruns outside the breaker lock, so several threads can call it at once. Pure functions of the request are unaffected. (#183)
Recommended middleware order changed. The docs now recommend AsyncCircuitBreaker → AsyncTimeout → AsyncBulkhead → AsyncRetry. In the old order, with AsyncTimeout first, the breaker saw an expired deadline as a cancellation and never counted it, so a hanging upstream never opened the circuit. Existing chains keep working; swap the first two entries to get timeouts counted. (#178)
Decoders:
MsgspecDecoderno longer claims aStruct, dataclass,TypedDictorNamedTuplewith a field msgspec cannot decode. Those requests now fail withMissingDecoderErrorbefore sending, instead of sending and then failing withDecodeError. With the default decoder list, such dataclasses andTypedDicts still go toPydanticDecoder. (#168)- Each decoder caches parsers for up to 4,096 models; the next new model after that empties the cache. Models created per request no longer grow memory without bound, but they get their parser rebuilt after each clear, so define response models once. (#191)
A | BandB | Ano longer share a cached adapter, so pydantic's member-order tie-break applies per call site. (#168)- A streamed response returned by middleware without being read now decodes, in both clients. (#168, #169)
Response body cap (max_response_body_bytes):
- A capped response keeps
elapsed,next_requestand the client'sdefault_encoding, matching an uncapped one. (#179) - A capped response without
Content-Encodingis now the response httpx2 built, read through a byte-counting stream, instead of a rebuilt copy. It reports the realnum_bytes_downloadedinstead of0and keeps itsTransfer-Encodingheader andnetwork_streamextension. Encoded responses are still rebuilt and still report0. (#192) - Redirect and auth hop bodies are read up to the cap and thrown away, so hops reuse the pooled connection. A five-hop redirect chain used to open six connections. A hop body larger than the cap is still not an error. (#179)
Performance
- A capped request with an unencoded body costs about 13 to 15 µs (20 to 25%) less; gzip bodies are unchanged. (#192)
- About 12 to 13 µs less httpware overhead on a mocked
get(), sync and async: the URL query merge is skipped when there is nothing to merge, and httpx2 exceptions are mapped inline. (#182) - Raising and catching a
StatusErrorcosts about 0.5 to 1.1 µs instead of 1.3 to 2.7 µs; the message is built only when read. (#180) - Circuit breakers admit into a closed circuit without taking a lock: about 25 to 35% less per call on the sync breaker, and 1.5 to 2× keyed-breaker throughput on free-threaded 3.14.
AsyncBulkheadskipsasyncio.timeoutwhen a slot is free (about 2 µs to 0.8 µs). (#183) - A rate-mode outcome costs about 1 µs instead of 3 µs.
RetryBudgetmemory stays constant: about 1.3 KB at 50k requests per second, where it used to reach 17 MB, and the long lock hold after an idle gap is gone. (#181)
Full Changelog: 0.21.0...0.22.0