Summary
When the host list has length 1, a transport-level failure is never retried — it is raised
to the caller on the first attempt. There is no same-host retry, so a single connection
error fails the call outright.
This affects any client configured with an explicit REST host, or with
fallbackHosts: []: Defaults.get_fallback_hosts returns [] for any endpoint containing
., :: or localhost, so those configurations get zero retries.
Mechanism
Http.make_request retries by advancing through self.get_hosts():
def should_stop_retrying(retry_count=retry_count):
time_passed = time.time() - requested_at
return retry_count == len(hosts) - 1 or time_passed > http_max_retry_duration
With one host, retry_count == len(hosts) - 1 is true on iteration 0, so the except
branch re-raises immediately. The retry dimension is which host, never try again.
Why this matters beyond configuration
A connection-level failure says nothing about host health. The case that exposed it in CI
was an HTTP/2 GOAWAY naming our own stream:
httpx.RemoteProtocolError: <ConnectionTerminated error_code:NO_ERROR, last_stream_id:1>
httpcore only recovers a ConnectionTerminated transparently when the client's stream id
is strictly greater than the GOAWAY's last_stream_id — i.e. the server is telling us it
never touched the request. Here they were equal (both stream 1: a brand-new connection
GOAWAY'd on its first request), which RFC 7540 §6.8 makes genuinely ambiguous, so httpcore
raises. A fresh connection to the same host would almost certainly have succeeded.
Reproduced with a local TLS/ALPN h2 server that sends GOAWAY(NO_ERROR, last_stream_id=1)
on the first connection's first stream and serves 200 thereafter: with one host the call
raises; with two it succeeds on the second.
Worth noting: keepalive_expiry, max_keepalive_connections and disabling HTTP/2 are all
irrelevant to this case, since the failure was on a new connection's first request.
Suggested direction
On an httpx.TransportError, retry the same host once (subject to
http_max_retry_duration) before advancing. Rationale: a connection failure is not
evidence about the host, a fresh connection usually succeeds, and it avoids burning a
fallback host that RSC15f then pins as preferred for ten minutes.
RSC15 describes fallback-host behaviour, not same-host retry, so this needs a spec
conversation first. Duplicate-request risk is no worse than what cross-host fallback
already incurs — see the nonce and cross-region-dedupe issues, which are the two places
that risk actually bites.
Minor httpcore note
last_stream_id of 0 — the correct GOAWAY for "I processed nothing" — is falsy, so it
also takes the raise branch rather than the retry branch. Not the cause here, but likely
worth reporting upstream.
🤖 Generated with Claude Code
┆Issue is synchronized with this Jira Task by Unito
Summary
When the host list has length 1, a transport-level failure is never retried — it is raised
to the caller on the first attempt. There is no same-host retry, so a single connection
error fails the call outright.
This affects any client configured with an explicit REST host, or with
fallbackHosts: []:Defaults.get_fallback_hostsreturns[]for any endpoint containing.,::orlocalhost, so those configurations get zero retries.Mechanism
Http.make_requestretries by advancing throughself.get_hosts():With one host,
retry_count == len(hosts) - 1is true on iteration 0, so theexceptbranch re-raises immediately. The retry dimension is which host, never try again.
Why this matters beyond configuration
A connection-level failure says nothing about host health. The case that exposed it in CI
was an HTTP/2 GOAWAY naming our own stream:
httpcore only recovers a
ConnectionTerminatedtransparently when the client's stream idis strictly greater than the GOAWAY's
last_stream_id— i.e. the server is telling us itnever touched the request. Here they were equal (both stream 1: a brand-new connection
GOAWAY'd on its first request), which RFC 7540 §6.8 makes genuinely ambiguous, so httpcore
raises. A fresh connection to the same host would almost certainly have succeeded.
Reproduced with a local TLS/ALPN h2 server that sends
GOAWAY(NO_ERROR, last_stream_id=1)on the first connection's first stream and serves 200 thereafter: with one host the call
raises; with two it succeeds on the second.
Worth noting:
keepalive_expiry,max_keepalive_connectionsand disabling HTTP/2 are allirrelevant to this case, since the failure was on a new connection's first request.
Suggested direction
On an
httpx.TransportError, retry the same host once (subject tohttp_max_retry_duration) before advancing. Rationale: a connection failure is notevidence about the host, a fresh connection usually succeeds, and it avoids burning a
fallback host that RSC15f then pins as preferred for ten minutes.
RSC15 describes fallback-host behaviour, not same-host retry, so this needs a spec
conversation first. Duplicate-request risk is no worse than what cross-host fallback
already incurs — see the nonce and cross-region-dedupe issues, which are the two places
that risk actually bites.
Minor httpcore note
last_stream_idof 0 — the correct GOAWAY for "I processed nothing" — is falsy, so italso takes the raise branch rather than the retry branch. Not the cause here, but likely
worth reporting upstream.
🤖 Generated with Claude Code
┆Issue is synchronized with this Jira Task by Unito