Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **HTTPRoute `timeouts.backendRequest` and `timeouts.request`.** A rule's
timeout is carried through routing.json to ghost, which bridges it onto the
fetch (`connect_timeout`, `first_byte_timeout`, `between_bytes_timeout`);
exceeding it returns 504 instead of Varnish's 503. `request` is applied as an
alias for `backendRequest` — when both are set the tighter value wins. `0s`
("disable") falls back to varnishd's global defaults rather than running
unbounded. Both conformance features are now declared.
- **`GatewayPort8080` conformance support (#30).** The multi-listener
architecture already mapped listener ports straight through to Service and
container ports, so the feature is now declared in the conformance suite.
This also enables the `HTTPRouteRedirectPortAndScheme` tests.

### Fixed

- **`deploy/01-operator.yaml` now sets `GATEWAY_IMAGE`.** The manifest left it
unset, so the operator fell back to its built-in
`ghcr.io/varnish/gateway-chaperone:latest`. On a `:latest` tag the node pulls
from ghcr.io, so `make deploy`, `make kind-deploy` and
`make test-conformance-kind` ran the published data plane instead of a locally
built one, and no ghost or VCL change could be verified against them.
`make deploy VERSION=vX` now pins the operator and the chaperone to the same
version.
- **Redirect port now derives from the Gateway listener, not the `Host`
header.** A `RequestRedirect` filter with no `scheme` and no `port` was
building the `Location` port from the client's `Host` header, so a request
Expand Down
2 changes: 2 additions & 0 deletions conformance/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ func TestConformance(t *testing.T) {
features.SupportHTTPRoute308RedirectStatusCode,
features.SupportHTTPRouteHostRewrite,
features.SupportHTTPRoutePathRewrite,
features.SupportHTTPRouteBackendTimeout,
features.SupportHTTPRouteRequestTimeout,
features.SupportGatewayHTTPListenerIsolation,
features.SupportHTTPRouteParentRefPort,
// BackendTLSPolicy: Varnish 9.0 does not expose a backend API field for
Expand Down
6 changes: 6 additions & 0 deletions deploy/01-operator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ spec:
image: ghcr.io/varnish/gateway-operator:latest
args:
- --leader-elect=true
env:
# Without this the operator falls back to its built-in default, which
# is a :latest tag the node pulls from ghcr.io — a locally built and
# kind-loaded data plane is never used. deploy-update rewrites the tag.
- name: GATEWAY_IMAGE
value: ghcr.io/varnish/gateway-chaperone:latest
ports:
- name: metrics
containerPort: 8080
Expand Down
106 changes: 106 additions & 0 deletions docs/reference/httproute-timeouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# HTTPRoute Timeouts

`HTTPRouteRule.timeouts` bounds how long the gateway waits on a backend.

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api-route
spec:
parentRefs:
- name: my-gateway
rules:
- matches:
- path:
type: PathPrefix
value: /slow-api
backendRefs:
- name: api-service
port: 8080
timeouts:
backendRequest: 5s
```

A route whose backend exceeds the timeout returns **504 Gateway Timeout**.

## `request` and `backendRequest`

| Field | Gateway API scope | Varnish behaviour |
|---|---|---|
| `backendRequest` | Gateway sends request headers → complete response received | `connect_timeout` + `first_byte_timeout` + `between_bytes_timeout` |
| `request` | Client request received → response fully sent | Same as `backendRequest` |

`request` is applied as an alias for `backendRequest`. When both are set the
tighter value wins — normally `backendRequest`, since the spec requires it to be
no larger than `request`.

## How it maps to Varnish

Varnish backends are pooled by `address:port`, so they cannot carry per-route
timeouts. Ghost bridges the value on the matched route to the fetch instead:

```
routing.json → ghost.json → ghost sets X-Ghost-Timeout on the request
→ vcl_backend_fetch sets bereq.connect_timeout + bereq.first_byte_timeout
+ bereq.between_bytes_timeout
→ vcl_backend_error reports 504 instead of 503
```

All three are set to the same value, so the route's budget bounds establishing the
connection as well as waiting for bytes. Gateway API scopes `backendRequest` to
after request headers are sent, but the bound users actually want is their own
wall-clock wait. A route with a tight timeout to an off-cluster backend
(ExternalName, or one using TLS) must complete the TCP and TLS handshake inside
that budget.

## Caveats

**Any fetch failure on a timeout route reports 504.** Varnish exposes no failure
reason in `vcl_backend_error`, so a refused connection on a route with
`backendRequest` set also returns 504 rather than 503. Routes without a timeout
are unaffected and keep 503.

**There is no total-request cap.** Gateway API scopes `request` to the whole
client request-response cycle and `backendRequest` to the complete response, but
Varnish has neither bound — the clock necessarily starts at the backend fetch, and
time spent reading the client request body or streaming back to a slow client is
not counted.

**Streaming responses are cut mid-body.** `between_bytes_timeout` bounds every
gap between response body bytes, not the total elapsed time, so a long-lived SSE
or streaming response on a route with a short `backendRequest` is terminated —
and once headers are delivered the 504 flip no longer applies, so the client sees
a truncated 200. Do not set `backendRequest` on streaming routes.

**`0s` falls back to varnishd defaults.** Gateway API defines `0s` as "disable
the timeout". Varnish always applies its fetch timeouts, so a disabled route
inherits the global varnishd values (`connect_timeout` 3.5s, `first_byte_timeout`
and `between_bytes_timeout` 60s each) rather than running unbounded. Setting only
one of the two fields to `0s` leaves the other in force.

**No retries.** A timed-out fetch fails immediately; Varnish only retries when
VCL calls `return (retry)`.

## Interaction with user VCL

The 504 flip lives in the gateway postamble, which is concatenated *after* user
VCL. A user-supplied `vcl_backend_error` that ends with `return (deliver)`
terminates VCL execution before the postamble runs, and the response keeps
Varnish's 503. Branch on `bereq.http.X-Ghost-Timeout` if you need custom
handling for timed-out routes:

```vcl
sub vcl_backend_error {
if (bereq.http.X-Ghost-Timeout) {
set beresp.status = 504;
set beresp.http.Content-Type = "application/json";
set beresp.body = {"{"error": "upstream timeout"}"};
return (deliver);
}
}
```

`X-Ghost-Timeout` is stripped from client requests in `vcl_recv` before routing,
so it cannot be spoofed. Like the cache policy headers, it stays on `bereq`
through the fetch and is therefore visible to the backend.
3 changes: 3 additions & 0 deletions ghost/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ pub struct Route {
/// Cache policy from VarnishCachePolicy. None means pass-through (no caching).
#[serde(default)]
pub cache_policy: Option<CachePolicy>,
/// Effective route timeout in milliseconds. Absent means varnishd's defaults.
#[serde(default)]
pub backend_timeout_ms: Option<u32>,
}

/// All routing rules for a single hostname (e.g., "api.example.com").
Expand Down
4 changes: 4 additions & 0 deletions ghost/src/director.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ pub struct RouteEntry {
pub cache_policy: Option<crate::config::CachePolicy>,
/// Pre-compiled bypass header rules (extracted from cache_policy at config load time).
pub bypass_headers: Vec<BypassHeaderCompiled>,
/// Effective route timeout in milliseconds, bridged to bereq via X-Ghost-Timeout.
pub backend_timeout_ms: Option<u32>,
}

/// Map of vhost directors for two-tier routing
Expand Down Expand Up @@ -348,6 +350,7 @@ pub fn build_vhost_directors(
rule_index: route.rule_index,
cache_policy: route.cache_policy.clone(),
bypass_headers,
backend_timeout_ms: route.backend_timeout_ms,
});
}

Expand Down Expand Up @@ -377,6 +380,7 @@ pub fn build_vhost_directors(
rule_index: i32::MAX,
cache_policy: None,
bypass_headers: Vec::new(),
backend_timeout_ms: None,
});
}

Expand Down
16 changes: 16 additions & 0 deletions ghost/src/vhost_director.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub struct RouteMatchResult<'a> {
pub route_name: Option<&'a str>,
pub cache_policy: Option<&'a crate::config::CachePolicy>,
pub bypass_headers: &'a [crate::director::BypassHeaderCompiled],
pub backend_timeout_ms: Option<u32>,
}

/// Result returned by route_request to the caller (recv/resolve).
Expand Down Expand Up @@ -387,6 +388,16 @@ impl VhostDirector {
// Determine cache behavior from policy
let pass = apply_cache_policy_headers(http, &match_result, &query_string_owned);

// Per-route backend timeout (HTTPRoute timeouts.backendRequest), bridged
// to bereq. vcl_backend_fetch turns this into first_byte_timeout /
// between_bytes_timeout, and the postamble vcl_backend_error uses its
// presence to report 504 rather than Varnish's default 503.
// Must unset first since set_header() appends a header slot.
if let Some(ms) = match_result.backend_timeout_ms {
http.unset_header("X-Ghost-Timeout");
let _ = http.set_header("X-Ghost-Timeout", &format!("{}ms", ms));
}

// Select backend using two-level weighted random:
// Level 1: pick a group by weight
// Level 2: pick a random pod within the selected group
Expand Down Expand Up @@ -521,6 +532,7 @@ fn match_routes<'a>(
route_name: route.route_name.as_deref(),
cache_policy: route.cache_policy.as_ref(),
bypass_headers: &route.bypass_headers,
backend_timeout_ms: route.backend_timeout_ms,
});
}

Expand Down Expand Up @@ -1155,6 +1167,7 @@ mod tests {
rule_index: 0,
cache_policy: None,
bypass_headers: Vec::new(),
backend_timeout_ms: None,
}];

// This test doesn't use HttpHeaders, so we can't fully test it here
Expand Down Expand Up @@ -1182,6 +1195,7 @@ mod tests {
rule_index: 0,
cache_policy: None,
bypass_headers: Vec::new(),
backend_timeout_ms: None,
}];

// Verify route structure
Expand Down Expand Up @@ -1212,6 +1226,7 @@ mod tests {
rule_index: 0,
cache_policy: None,
bypass_headers: Vec::new(),
backend_timeout_ms: None,
}],
backend_pool.clone(),
None,
Expand Down Expand Up @@ -1311,6 +1326,7 @@ mod tests {
route_name: Some("default/my-route"),
cache_policy: None,
bypass_headers: &[],
backend_timeout_ms: None,
};

assert_eq!(result.backend_groups.len(), 1);
Expand Down
140 changes: 140 additions & 0 deletions ghost/tests/test_backend_timeout.vtc
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
varnishtest "per-route backendRequest timeout maps to 504"

# Slow backend: holds the connection open well past the route's 500ms timeout.
# Deliberately never sends a response — writing one after Varnish has already
# given up and closed the backend connection races with the test harness.
server s_slow {
rxreq
delay 2
} -start

# Fast backend on a route that also carries a timeout: must not be affected.
server s_fast {
rxreq
txresp -body "on-time"
} -start

# Slow backend on a route with NO timeout configured: proves the 504 flip is
# scoped to timeout routes and does not turn every fetch failure into a 504.
server s_untimed {
rxreq
delay 1
txresp -body "untimed"
} -start

shell {
cat > ${tmpdir}/ghost.json <<EOF
{
"version": 2,
"vhosts": {
"api.example.com": {
"routes": [
{
"path_match": {"type": "PathPrefix", "value": "/slow"},
"backend_groups": [
{"weight": 100, "backends": [
{"address": "${s_slow_addr}", "port": ${s_slow_port}}
]}
],
"backend_timeout_ms": 500,
"priority": 10300
},
{
"path_match": {"type": "PathPrefix", "value": "/fast"},
"backend_groups": [
{"weight": 100, "backends": [
{"address": "${s_fast_addr}", "port": ${s_fast_port}}
]}
],
"backend_timeout_ms": 500,
"priority": 10300
},
{
"path_match": {"type": "PathPrefix", "value": "/untimed"},
"backend_groups": [
{"weight": 100, "backends": [
{"address": "${s_untimed_addr}", "port": ${s_untimed_port}}
]}
],
"priority": 10300
}
]
}
}
}
EOF
}

# Mirrors the generated preamble/postamble: the timeout bridge lives in
# vcl_backend_fetch and the 503 -> 504 flip in vcl_backend_error.
# Global first_byte_timeout is raised so the /untimed route is governed by the
# route config (i.e. no timeout) rather than by a short varnishd default.
varnish v1 -arg "-p thread_pool_stack=160k" -arg "-p first_byte_timeout=10" -vcl {
import ghost from "${vmod}";
import std;

backend dummy none;

sub vcl_init {
ghost.init("${tmpdir}/ghost.json");
new router = ghost.ghost_backend();
}

sub vcl_recv {
unset req.http.X-Ghost-Timeout;
if (req.url == "/.varnish-ghost/reload") {
if (router.reload()) {
return (synth(200, "OK"));
} else {
return (synth(500, "Reload failed"));
}
}
set req.backend_hint = router.recv();
return (pass);
}

sub vcl_backend_fetch {
if (bereq.http.X-Ghost-Timeout) {
set bereq.first_byte_timeout = std.duration(bereq.http.X-Ghost-Timeout, 60s);
set bereq.between_bytes_timeout = std.duration(bereq.http.X-Ghost-Timeout, 60s);
}
}

sub vcl_backend_error {
if (bereq.http.X-Ghost-Timeout) {
set beresp.status = 504;
set beresp.reason = "Gateway Timeout";
set beresp.ttl = 0s;
}
}
} -start

client c_reload {
txreq -url "/.varnish-ghost/reload"
rxresp
expect resp.status == 200
} -run

# Backend exceeds the route timeout -> 504, not 503.
client c_slow {
txreq -url "/slow" -hdr "Host: api.example.com"
rxresp
expect resp.status == 504
expect resp.reason == "Gateway Timeout"
} -run

# Same timeout, backend responds in time -> untouched.
client c_fast {
txreq -url "/fast" -hdr "Host: api.example.com"
rxresp
expect resp.status == 200
expect resp.body == "on-time"
} -run

# No timeout on the route -> slow backend still succeeds.
client c_untimed {
txreq -url "/untimed" -hdr "Host: api.example.com"
rxresp
expect resp.status == 200
expect resp.body == "untimed"
} -run
Loading
Loading