Skip to content

Add an overall upstream response-header deadline distinct from read_timeout #992

Description

@seonghobae

What problem does this solve?

Pingora currently exposes PeerOptions::read_timeout as a per-read inactivity timeout. That is useful for a silent upstream, but it does not bound the total time spent receiving an incomplete response header when the upstream keeps making small amounts of progress before each individual read timeout expires.

At current main commit 09696b51bc59315353d96686355861604d0bb48c, the HTTP/1 client HttpSession::read_response() keeps partial bytes in response_header_read_buf, then wraps each underlying_stream.read_buf(...) in a fresh read_timeout. Any successful read loops back to parsing and receives a new timeout. The public peer documentation and both H1/H2 client fields also describe read_timeout as resetting on every read and explicitly not being an overall response-duration timeout.

That means an upstream can send an incomplete response header one byte/fragment at a time, each fragment arriving inside read_timeout, and keep a request occupied without ever completing \r\n\r\n or violating the inactivity timeout.

This cannot be implemented reliably by a ProxyHttp application today: upstream_response_filter is invoked only after the response header has arrived, while body filters are later still. total_connection_timeout is not a substitute because it bounds connection establishment (including TLS), not the response-header phase.

Desired behavior

Please consider an additive, explicit overall upstream response-header deadline (name/API shape up to maintainers) that is distinct from read_timeout.

The important semantics would be:

  • the deadline spans repeated successful reads while one response header is incomplete;
  • per-read read_timeout remains independently useful as an inactivity timeout;
  • HTTP/1 and HTTP/2 behavior is explicit;
  • informational (1xx) responses have documented semantics: e.g. whether the budget applies independently to each header block or to the entire pre-final-response header phase;
  • timeout cancellation is safe and does not leave response-header parsing state or pooled connection reuse in an inconsistent state;
  • an unset value preserves current behavior.

A peer option is one possible surface, but an equivalent cancellation-safe hook would also solve the application-level gap if it can actually terminate a pending header read at the overall deadline.

Reproduction shape

A deterministic regression can use an origin that:

  1. accepts the upstream request;
  2. starts sending a syntactically valid-but-incomplete status/header block;
  3. sends one byte or small fragment every N ms where N < read_timeout;
  4. deliberately withholds the terminating \r\n\r\n past the configured overall header deadline.

Expected with a new overall deadline: the request fails at approximately that total header budget even though every individual read made progress inside read_timeout.

This is specifically about bounding the response-header phase. It is separate from whole-response/body lifetime policy and from application-level streaming semantics.

Activity

seonghobae commented on Sep 3, 2026

@seonghobae
Author

Source pass against current protected main / pinned 09696b51bc59315353d96686355861604d0bb48c narrows the protocol semantics for this request:

  • H1 HttpSession::read_response() keeps partial bytes in response_header_read_buf, reparses them after cancellation/resume, and applies read_timeout around each individual read_buf(). A successful partial read therefore refreshes the inactivity timer. The same method can be called repeatedly for informational responses.
  • H2 is materially different today: Http2Session::read_response_header() applies read_timeout once around poll_response_header(). It also has an explicit TODO for 1xx support, so the H1 byte-drip failure mode should not be mechanically projected onto current H2 behavior.
  • PeerOptions::read_timeout is plumbed independently into the H1/H2 sessions by the proxy paths. Reinterpreting it as the new overall deadline would therefore change an existing inactivity-timeout contract.

A cancellation-safe implementation appears to need a separate, monotonic response-header deadline stored with the response-header sequence, rather than constructing a fresh relative timeout on every read_response() call. For H1 that lets partial parser state survive ordinary task cancellation without allowing successful reads—or repeated 1xx responses—to reset the absolute budget. The deadline should be cleared only when the final non-informational response header is accepted or the request is abandoned/failed. This also makes the 1xx policy explicit: one budget from the first response-header wait through the final non-1xx header, rather than one new budget per informational response.

Suggested acceptance cases for the supplier change:

  1. H1 origin sends an incomplete header in fragments where every fragment arrives before read_timeout, but total header time exceeds the new deadline: fail at the overall deadline.
  2. Same H1 traffic with the new option unset: preserve current per-read behavior.
  3. H1 cancellation/resume before expiry: preserve accumulated bytes and the original absolute deadline.
  4. H1 informational-response sequence: 1xx responses do not refresh the deadline for the eventual final response.
  5. H2 final-response wait: the new option is independently enforced without changing existing read_timeout; document the current lack of 1xx support rather than pretending H1/H2 parser behavior is identical.
  6. Completed header inside both budgets remains unchanged.

I would keep the exact public field/API name open until the owner decides where it belongs, but semantically it should remain distinct from read_timeout and total_connection_timeout.

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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions