Skip to content

A single-host configuration gets no retry at all on a transport error #711

Description

@owenpearson

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions