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
- Proxy a request to an upstream that accepts the TCP connection but never answers.
- 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:
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.
Describe the bug
The default
fail_to_proxyimplementation 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
This means
ConnectTimedout,TLSHandshakeTimedout,ReadTimedout, andWriteTimedout— 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
Minimal reproduction with the stock
ProxyHttpimplementation (nofail_to_proxyoverride):With the default
read_timeoutof 30s the client waits out the deadline and then receives:Expected results
Deadline expiries against the upstream answer 504 Gateway Timeout:
Everything else upstream-sourced (refused, unroutable, TLS failures, mid-body resets) keeps 502.
Observed results
502 for both classes, so:
ProxyHttpwho 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.