Skip to content

Upstream timeouts (connect/read/write) synthesize 502 instead of 504 in fail_to_proxy's default status mapping #980

Description

@acc0mplish

Describe the bug

The default fail_to_proxy implementation maps every upstream-sourced error to 502:

https://github.com/cloudflare/pingora/blob/pingora-proxy-0.8.1/pingora-proxy/src/proxy_trait.rs#L489-L516

ErrorSource::Upstream => 502,

This means ConnectTimedout, TLSHandshakeTimedout, ReadTimedout, and WriteTimedout — all deadline expiries against the upstream — surface to clients as 502 Bad Gateway.

RFC 9110 §15.6.6 defines 504 specifically for this case: "the server, while acting as a gateway or proxy, did not receive a timely response from an upstream server." A timed-out upstream and a broken upstream are different failure modes, and the status code is the only signal most clients and dashboards get.

For reference, both nginx and Envoy answer 504 when their upstream connect/read/write deadlines expire (nginx: proxy_connect_timeout / proxy_read_timeout / proxy_send_timeout; Envoy: route/cluster timeouts map to gateway-timeout responses).

Pingora info

Pingora version: 0.8.1
Rust version: any (mapping is version-independent since the hook exists)
Operating system version: any

Steps to reproduce

  1. Proxy a request to an upstream that accepts the TCP connection but never answers.
  2. Let the read timeout expire.

Minimal reproduction with the stock ProxyHttp implementation (no fail_to_proxy override):

$ curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/   # upstream accepts, never responds
502

With the default read_timeout of 30s the client waits out the deadline and then receives:

HTTP/1.1 502 Bad Gateway

Expected results

Deadline expiries against the upstream answer 504 Gateway Timeout:

ErrorSource::Upstream => match e.etype() {
    ErrorType::ConnectTimedout
    | ErrorType::TLSHandshakeTimedout
    | ErrorType::ReadTimedout
    | ErrorType::WriteTimedout => 504,
    _ => 502,
},

Everything else upstream-sourced (refused, unroutable, TLS failures, mid-body resets) keeps 502.

Observed results

502 for both classes, so:

  • monitoring cannot distinguish "upstream slow/hung" from "upstream broken";
  • browsers render a timeout as "Bad gateway", which misleads users;
  • operators building on ProxyHttp who want the RFC-compliant mapping have to copy the entire default mapping into an override just to add the timeout arms (and keep the copy in sync across upgrades).

A secondary suggestion attached to the same code: extracting the default mapping into a public helper (e.g. pub fn default_failure_status(e: &Error) -> u16) would let implementations reuse the mapping and only override the parts they disagree with, instead of forking the whole hook body.

Happy to send a PR for the mapping change, the helper extraction, or both, if the direction sounds right.

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