Files
vpsetup/docs/research/caddy-grpc-upstream-h2.md

512 lines
35 KiB
Markdown

# Caddy v2.11.4 上游 gRPC/h2 长流行为:源码级调研报告
> 来源:2026-10-11 由研究代理产出(克隆 caddyserver/caddy@v2.11.4 + Go 1.25.1 h2_bundle.go + GitHub issues/PR + 官方文档),原文存于当时的 /tmp。
> 结论摘要与决策要点见 docs/HANDOFF.md §11 / §11.1;本文件保留完整引用与逐行证据。
Scope: Caddy **v2.11.4** (tag `e2eee6a7fce366321294c9c2a79f3146891dcbdf`, go.mod `go 1.25.1`), Go 1.25.x
`net/http` + bundled `golang.org/x/net/http2`, Traefik v3.x. Primary sources only.
Anything not provable from a source I actually read is marked **UNCONFIRMED**.
Local verification material (read-only, under /tmp):
`/tmp/caddy-grpc-research/caddy-src/` (git clone, tags `v2.11.4` + `master`),
`/tmp/caddy-grpc-research/h2_bundle_125.go` (Go 1.25.1 `src/net/http/h2_bundle.go`),
`/tmp/caddy-grpc-research/h2server.go` (x/net/http2 server.go, master),
`/tmp/caddy-grpc-research/ht_v2114.go`, `/tmp/caddy-grpc-research/cf_v2114.go` (v2.11.4 transports).
---
## 1. How `reverse_proxy` handles gRPC specifically — it does not
**Finding 1.1 — there is no gRPC code in `reverse_proxy` at all.**
`git grep -n "grpc" v2.11.4 -- '*.go'` over the whole Caddy repo returns only three files, and none is the
proxy: `modules/caddyhttp/matchers.go` (the `protocol grpc` *request matcher*),
`modules/caddyhttp/celmatcher_test.go`, `modules/caddyhttp/reverseproxy/addresses_test.go` (a `unix+h2c//var/grpc.sock`
address example). The matcher is the only gRPC-aware code:
```go
// modules/caddyhttp/matchers.go:1370 (v2.11.4)
case "grpc":
return strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc"), nil
```
Maintainer confirmation (Caddy forum, Francis Lavoie, Mar 2021 — [link](https://caddy.community/t/proxying-streaming-grpc-with-caddy-2/11973)):
> "Where are you seeing support for this? I don't think `reverse_proxy` directly supports `grpc://`.
> I don't see anything in the codebase for this. The `protocol` matcher does support `grpc`, but that's
> totally separate from `reverse_proxy`."
**Finding 1.2 — buffering/timeouts are switched off generically, not by Content-Type.**
`flushInterval()` in `modules/caddyhttp/reverseproxy/streaming.go` (v2.11.4):
```go
func (h Handler) flushInterval(req *http.Request, res *http.Response) time.Duration {
resCTHeader := res.Header.Get("Content-Type")
resCT, _, err := mime.ParseMediaType(resCTHeader)
// For Server-Sent Events responses, flush immediately.
if err == nil && resCT == "text/event-stream" { return -1 }
// We might have the case of streaming for which Content-Length might be unset.
if res.ContentLength == -1 { return -1 }
// for h2 and h2c upstream streaming data to client (issues #3556 and #3606)
if h.isBidirectionalStream(req, res) { return -1 }
return time.Duration(h.FlushInterval)
}
```
The Handler field comment is explicit:
> `FlushInterval caddy.Duration` — "This option is ignored when the upstream's response is recognized as a
> streaming response, or if its content length is -1; for such responses, writes are flushed to the client
> immediately." — `modules/caddyhttp/reverseproxy/reverseproxy.go:139`
The docs say the same ([reverse_proxy → Streaming](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#streaming)):
> "**flush_interval** … A negative value (typically -1) suggests 'low-latency mode' … **This option is ignored and
> responses are flushed immediately to the client if one of the following applies from the response:**
> `Content-Type: text/event-stream`; `Incremental: ?1`; **`Content-Length` is unknown**; HTTP/2 on both sides of
> the proxy, `Content-Length` is unknown, and `Accept-Encoding` is either not set or is 'identity'."
>
> "By default, the proxy partially buffers the response for wire efficiency."
**Conclusion for Q1:** gRPC responses carry no `Content-Length` (they are DATA + trailers), so Caddy already
flushes every write immediately. **`flush_interval -1` is NOT required for server-streaming gRPC** — it is a no-op
for that case (harmless as belt-and-braces, but it will not fix anything). This matches the historical thread where
Matt Holt asked "What happens if you set `flush_interval -1` explicitly?" and the reporter answered **"No change,
unfortunately."**
Two residual caveats:
* `request_buffers` defaults to `0` (no request buffering), so gRPC request bodies stream through — do **not** set it.
* Bidirectional/interleaved gRPC was genuinely broken in 2021 (that same thread: *"it appears it doesn't handle a
client trying to read the server metadata … before sending its request"*); those threads were closed as Caddy
moved on. By v2.11.4 the code explicitly names gRPC as a supported bi-directional case (PR #7719 body, below), so
that old limitation should not be assumed. **UNCONFIRMED** that any interleaving limitation remains in v2.11.4 —
I found no open issue for it.
---
## 2. Upstream HTTP/2 keepalive semantics, and stale pooled connections
### 2.1 What the `transport http` options actually do (v2.11.4)
Verified in `modules/caddyhttp/reverseproxy/caddyfile.go:1255-1310` and `httptransport.go:212-216, 477-495`:
| Caddyfile key | Go field | What it really is |
|---|---|---|
| `keepalive <dur>` / `off` | `KeepAlive.IdleConnTimeout` → `http.Transport.IdleConnTimeout` | **How long an idle connection is kept in the pool.** Default `2m`. Not "keep the connection alive for ever". |
| `keepalive_interval <dur>` | `KeepAlive.ProbeInterval` → `net.Dialer.KeepAlive` + `net.KeepAliveConfig{Enable, Interval}` | **TCP keepalive probes only.** Default `30s`. It is **not** an HTTP/2 PING. |
| `keepalive_idle_conns <n>` | `MaxIdleConns` | Default 0 = no limit |
| `keepalive_idle_conns_per_host <n>` | `MaxIdleConnsPerHost` | Default 32 |
Struct docs, verbatim (`httptransport.go`):
```go
// Whether HTTP Keep-Alive is enabled. Default: `true`
Enabled *bool `json:"enabled,omitempty"`
// How often to probe for liveness. Default: `30s`.
ProbeInterval caddy.Duration `json:"probe_interval,omitempty"`
// How long connections should be kept alive when idle. Default: `2m`.
IdleConnTimeout caddy.Duration `json:"idle_timeout,omitempty"`
```
```go
dialer.KeepAlive = time.Duration(h.KeepAlive.ProbeInterval)
dialer.KeepAliveConfig = net.KeepAliveConfig{
Enable: h.KeepAlive.ProbeInterval > 0,
Interval: time.Duration(h.KeepAlive.ProbeInterval),
}
rt.IdleConnTimeout = time.Duration(h.KeepAlive.IdleConnTimeout)
```
**`keepalive_idle_timeout` and `keepalive_probe_interval` do not exist in `transport http`.** The parser accepts only
`keepalive`, `keepalive_interval`, `keepalive_idle_conns`, `keepalive_idle_conns_per_host`. (`keepalive_idle` /
`keepalive_count` *do* exist — but as **global server options** on the listener side: `servers { keepalive_interval …
keepalive_idle … keepalive_count … }`, added in PR [#7298](https://github.com/caddyserver/caddy/pull/7298). That is a
likely source of the naming confusion.)
**Finding 2.2 (important, and counter-intuitive): Caddy configures no HTTP/2 PING health check on upstreams.**
`git grep -n "ReadIdleTimeout\|PingTimeout\|WriteByteTimeout" v2.11.4 -- '*.go'` → **zero matches.** Go's
`http2Transport` reads those from `configFromTransport` (`readIdleTimeout: conf.SendPingTimeout`,
`pingTimeout: conf.PingTimeout` — `h2_bundle_125.go:8117`), and Caddy never sets them, so `ReadIdleTimeout = 0` =
no health-check PING. **`keepalive_interval 15s` therefore does *not* keep the upstream h2 connection warm and does
not detect a half-open h2 connection.** (Corroborating issue history:
[#7144 "Keep-alive interval may be set incorrectly"](https://github.com/caddyserver/caddy/issues/7144) →
[#7151 "Use KeepAliveConfig to pass keepalive_interval to listener's accepted sockets"](https://github.com/caddyserver/caddy/pull/7151).)
### 2.3 Does a request fail fast on a stale pooled h2 connection? **Sometimes — and that is the crux.**
From Go 1.25.1 `src/net/http/h2_bundle.go` (verbatim):
```go
func http2shouldRetryRequest(req *Request, err error) (*Request, error) {
if !http2canRetryError(err) { return nil, err }
// If the Body is nil (or http.NoBody), it's safe to reuse this request and its Body.
if req.Body == nil || req.Body == NoBody { return req, nil }
// If the request body can be reset back to its original state via req.GetBody, do that.
if req.GetBody != nil { ... }
// The Request.Body can't reset back to the beginning, but we don't seem to have
// started to read from it yet, so reuse the request directly.
if err == http2errClientConnUnusable { return req, nil }
return nil, fmt.Errorf("http2: Transport: cannot retry err [%v] after Request.Body was written; define Request.GetBody to avoid this error", err)
}
func http2canRetryError(err error) bool {
if err == http2errClientConnUnusable || err == http2errClientConnGotGoAway { return true }
if se, ok := err.(http2StreamError); ok {
if se.Code == http2ErrCodeProtocol && se.Cause == http2errFromPeer { return true }
return se.Code == http2ErrCodeRefusedStream
}
return false
}
```
* `errClientConnUnusable` — "http2: client conn not usable" — is raised when the pooled conn *already* fails
`canTakeNewRequestLocked` (`goAway != nil || closed || closing || doNotReuse || tooIdleLocked() ||
at MAX_CONCURRENT_STREAMS`). This **is** retried transparently, even for a non-resettable body.
* `errClientConnGotGoAway` — "http2: Transport received Server's graceful shutdown GOAWAY" — this is what
`setGoAway` assigns to **streams already in flight whose ID > LastStreamID**:
`cs.abortStreamLocked(http2errClientConnGotGoAway)` with the comment *"Aborting the stream with
errClentConnGotGoAway indicates that the request should be retried on a new connection."*
**But the retry is then blocked by the body check above.**
* `ErrCodeRefusedStream` likewise passes `canRetryError` but is then blocked by the body check.
* `errClientConnNotEstablished` (first request on a brand-new conn that died) **"aborts the retry loop"** fatally.
**And Caddy never sets `GetBody`:** `git grep -n GetBody v2.11.4 -- modules/caddyhttp/` returns nothing. Caddy's
cloned upstream request is built from the inbound `*http.Request`, whose `GetBody` is nil for an inbound gRPC POST.
⇒ **A GOAWAY (or a REFUSED_STREAM reset) that lands on a gRPC POST whose body is not resettable produces an
immediate upstream round-trip error, which `statusError()` turns into `502 Bad Gateway`. A merely idle/closed pooled
connection does not — it is silently retried on a fresh connection.**
**Issue-tracker status of this race:** I found **no** Caddy issue dedicated to "stale pooled h2 conn → 502"
(**UNCONFIRMED** that one exists). The nearest open reports are:
* [#8185 (open, 2026-10-09) "reverseproxy: request hangs when HTTP/2 upstream resets stream while request body read is pending"](https://github.com/caddyserver/caddy/issues/8185) — see §3.5.
* [#8143 (open, 2026-10-04) "core: config reload cleans up handler modules while requests are still running"](https://github.com/caddyserver/caddy/issues/8143).
* [#8166 (closed) "reverseproxy: lb_retry_match with a request-only expression retries successful responses (502 since v2.11.3)"](https://github.com/caddyserver/caddy/issues/8166).
* Historical, resolved: [#3236 "http/2 transport failing for gRPC"](https://github.com/caddyserver/caddy/issues/3236) (2020),
[#3938 "Caddy is unable to reverse proxy http/2 streams"](https://github.com/caddyserver/caddy/issues/3938) (2020/21,
`kubectl logs -f` → client `Unexpected EOF`, right after `MAX_CONCURRENT_STREAMS updated` — closed for inactivity).
---
## 3. Candidates that can kill a stream ~200 ms after it opens, and how to tell them apart
**3.0 First, why the client sees _two_ different errors** — this is diagnostic gold.
`reverseproxy.go:1278-1286` (v2.11.4):
```go
if err != nil {
// we're streaming the response and we've already written headers, so
// there's nothing an error handler can do to recover at this point;
// we'll just log the error and abort the stream here and panic just as
// the standard lib's proxy to propagate the stream error.
// see issue https://github.com/caddyserver/caddy/issues/5951
if c := logger.Check(zapcore.WarnLevel, "aborting with incomplete response"); c != nil {
c.Write(zap.Error(err))
}
panic(http.ErrAbortHandler)
}
```
Go's h2 server turns a handler panic into a stream reset (`h2server.go:2329`,
`write: handlerPanicRST{rw.rws.stream.id}`) and stays silent for `ErrAbortHandler`
(`if e != nil && e != http.ErrAbortHandler { … sc.logf("http2: panic serving …") }`).
⇒ **Same upstream failure, two client-visible faces depending on timing:**
* failure **before** `rw.WriteHeader(res.StatusCode)` → Caddy **502** → gRPC `Unavailable: unexpected HTTP status code received`;
* failure **after** headers were copied but mid-body → `ErrAbortHandler` → **RST_STREAM** → gRPC `Internal: stream terminated by RST_STREAM`.
Seeing *both* messages alternating is exactly what an upstream-side failure race looks like; it is **not** evidence of
two different bugs.
| # | Candidate | Verdict for this deployment | How to distinguish |
|---|---|---|---|
| 3.1 | `flush_interval` response buffering | **Ruled out.** §1.2: unknown `Content-Length` ⇒ already flushed immediately. | Add `flush_interval -1`; if nothing changes, as documented. |
| 3.2 | `MAX_CONCURRENT_STREAMS` mismatch | **Ruled out as a fast-failure.** Go's h2 *client* does not fail — it waits on a cond var: `awaitOpenSlotForStreamLocked` loops `cc.cond.Wait()` until `currentRequestCountLocked() < maxConcurrentStreams`. Go's h2 *server* RSTs excess streams: `return sc.countError("over_max_streams", streamError(id, ErrCodeProtocol))` / `("over_max_streams_race", streamError(id, ErrCodeRefusedStream))` (`h2server.go:1922-1936`) — **it does not GOAWAY**. | Symptom would be **latency/queueing**, not fast errors. But note the tail: an RST(REFUSED_STREAM) landing on a non-resettable body ⇒ §2.3 ⇒ 502. Traefik default `http2.maxConcurrentStreams` = **250** (docs table below). |
| 3.3 | Stale pooled connection | **Leading candidate.** Closed-idle conn → silently retried; **GOAWAY / REFUSED_STREAM on an in-flight gRPC POST → instant 502** because Caddy sets no `GetBody` (§2.3). | DEBUG log `"upstream roundtrip"` with `error` — the error string is decisive (`… graceful shutdown GOAWAY`, `… cannot retry err […] after Request.Body was written`, `http2: client conn not usable`, `http2: client conn could not be established`). Then `ss -tin dst :10443` / tcpdump: a fresh TLS handshake per failure ⇒ pooled-conn churn; a single long-lived conn ⇒ not this. |
| 3.4 | `Host` / `:authority` mismatch | **Unlikely here but cheap to rule out.** `header_up Host` really does rewrite `:authority` — `modules/caddyhttp/headers/headers.go:349-374` pulls `r.Host` into `r.Header["Host"]`, applies ops, then writes back `r.Host = r.Header["Host"][last]`. Since Caddy v2.11.0 the Host is auto-set to the upstream host for HTTPS upstreams (docs + [#7565](https://github.com/caddyserver/caddy/issues/7565)). | A wrong Host yields a **normal HTTP response from Traefik** (404/421), not a Caddy-generated 502: access-log `status` = the upstream's status, `resp_headers` show Traefik's, and there is **no** `http.handlers.reverse_proxy` WARN/ERROR. Traefik's own access log shows the `:authority` it received. |
| 3.5 | **v2.11.4 regression in `bodyNopCloserIfNotRead`** | **Real, version-exact, but produces a HANG not a fast error.** PR [#7719](https://github.com/caddyserver/caddy/pull/7719) ("first released in v2.11.4") introduced `bodyNopCloserIfNotRead`; its own PR body says the previous `io.NopCloser` approach "would make transport unable to close the body in legitimate cases such as **bi-directional streaming, i.e. grpc and extended websockets over h2/h3**". [#8185](https://github.com/caddyserver/caddy/issues/8185): *"The `bodyNopCloserIfNotRead` wrapper does not close the underlying request body in its `Close` method while its `Read` method is waiting to receive the first byte, even after the HTTP/2 transport has asked to close it (following an `RST_STREAM` signal received from the upstream). As a result, the blocked `Read` never returns, and the request hangs."* Repro: *"an upstream HTTP/2 server that accepts a request, waits a short time (about 300 ms), and then aborts the stream (`RST_STREAM`) without sending any response headers"*; *"the hang happened every time (8 out of 8 requests) on v2.11.4 … and never on v2.11.3."* | Client-side **deadline exceeded** / no response at all, with Caddy logging nothing, distinguishes it from §3.3's instant 502. **Recommendation: do not "fix" this by upgrading blindly** — v2.11.6 is the release that introduced the new 1-minute idle timeouts, see 3.6. |
| 3.6 | Caddy's own server `timeouts` | **Ruled out on v2.11.4.** v2.11.4 defaults only `idle 5m` and `read_header 1m`; `read_body`/`write` are unset = no timeout (`modules/caddyhttp/app.go`: `defaultIdleTimeout = 5 * time.Minute`, `defaultReadHeaderTimeout = time.Minute`). **But v2.11.6 changed this** — release notes: *"New 1-minute idle read/write timeouts by default. If a request body read or a response write stalls (makes no progress at all) for longer than that, the connection is aborted."* and it regressed streaming: [#8103](https://github.com/caddyserver/caddy/issues/8103) (*"v2.11.4 … is not affected"*), [#8118 "write_idle (v2.11.6) causes h2 streaming responses that are idle for the specified duration to fail"](https://github.com/caddyserver/caddy/issues/8118), [#8101 panic in `IdleTimeoutReader`](https://github.com/caddyserver/caddy/issues/8101). | If you upgrade past v2.11.4, pin `timeouts { read_body_idle 0 write_idle 0 }` (verify semantics on your version) or stay on v2.11.4. |
| 3.7 | Transport `read_timeout` / `write_timeout` | **Ruled out (both unset by default) but a real trap: never set them.** They are enforced by wrapping the **raw `*net.TCPConn`** (`tcpRWTimeoutConn.Read/Write` do `SetReadDeadline(now+timeout)` per call) — an idle gRPC stream would be killed. `httptransport.go:355-359`. | `transport http { read_timeout … }` present in the config. |
| 3.8 | Config reload | **Plausible and easy to miss.** `Cleanup()` closes the handler's connections; [#8143](https://github.com/caddyserver/caddy/issues/8143) shows module cleanup can run while requests are still active. | `journalctl -u caddy` for reloads; correlate reload timestamps with stream deaths. `stream_close_delay` is the mitigation hook (it exists precisely for "avoid a thundering herd of reconnecting clients"). |
| 3.9 | Traefik-side h2 / GOAWAY | **See §3.10.** | Traefik access log per hop + `keepAliveMaxRequests` config. |
### 3.10 Traefik defaults (primary source: <https://doc.traefik.io/traefik/routing/entrypoints/>)
| Option | Documented default |
|---|---|
| `transport.respondingTimeouts.readTimeout` | **60s** |
| `transport.respondingTimeouts.writeTimeout` | **0s** (no timeout) |
| `transport.respondingTimeouts.idleTimeout` | **180s** |
| `http2.maxConcurrentStreams` | **250** |
| `transport.keepAliveMaxRequests` | 0 (no limit) — *"Set the maximum number of requests Traefik can handle before sending a `Connection: Close` header to the client (**for HTTP2, Traefik sends a GOAWAY**)"* |
| `transport.keepAliveMaxTime` | 0s (no limit) — same GOAWAY wording |
Traefik→backend (<https://doc.traefik.io/traefik/reference/routing-configuration/http/load-balancing/serverstransport/>):
`forwardingTimeouts.dialTimeout` 30s, `responseHeaderTimeout` 0s, `idleConnTimeout` **90s**,
`readIdleTimeout` **0s**, `pingTimeout` **15s**.
**The GOAWAY chain is now provable end-to-end:**
1. Go's h2 server sends a graceful GOAWAY when it goes idle — `h2server.go`, verbatim:
```go
case idleTimerMsg:
sc.vlogf("connection is idle")
sc.goAway(ErrCodeNo)
```
with `sc.idleTimer = time.AfterFunc(sc.srv.IdleTimeout, sc.onIdleTimer)` (Traefik arms this from
`entryPoints.<name>.transport.respondingTimeouts.idleTimeout`). Same on graceful shutdown:
`sc.shutdownOnce.Do(func() { sc.sendServeMsg(gracefulShutdownMsg) })` (`h2server.go:1337`).
2. Caddy's pooled h2 conn receives it; `setGoAway` aborts in-flight streams with ID > LastStreamID using
`errClientConnGotGoAway`.
3. Go will not retry that on a request with a non-resettable body (§2.3) and Caddy sets no `GetBody`
⇒ instant **502** (or RST_STREAM if headers already went out).
**The structural asymmetry to exploit:** Caddy's `keepalive` (pool idle timeout, default **2m**) and Traefik's
`idleTimeout` (default **180s**) are independent. **Whichever side closes the idle h2 connection first wins the
race;** if Traefik closes first you get a GOAWAY into Caddy's pool. The user reports ~300 s direct, which is more
than Traefik's documented 180 s default — so that value is configured, **UNCONFIRMED** at what. If the effective
Traefik idle timeout is *below* 120 s, the GOAWAY window is hit while Caddy still trusts the connection.
---
## 4. Recommended Caddy config for reliably proxying long-lived gRPC to a Traefik/Go h2 origin
Diagnosis-first — apply §5 logging **before** changing behaviour, then:
```
netbird.savorcare.com {
bind 127.0.0.1 [::1]
tls /etc/caddy/certs/netbird/fullchain.pem /etc/caddy/certs/netbird/key.pem {
alpn h2 http/1.1
}
reverse_proxy https://qhxxg.savorcare.com:10443 {
# Host/:authority — keep, it is correct AND it is what Traefik routes on.
header_up Host netbird.savorcare.com
# Make Caddy drop the pooled h2 conn BEFORE the origin's idle timeout can
# GOAWAY us. Pick ≈ half the origin's entryPoint idleTimeout.
# Do NOT raise this above the origin's idle timeout.
# keepalive off -> never reuse; maximum safety, worst latency (good A/B test)
keepalive 60s
# Belt-and-braces only: a no-op for gRPC (Content-Length unknown already forces
# immediate flush). Costs nothing, removes one variable.
flush_interval -1
# OPTIONAL: only after you have verified the origin answers 200 for this URI
# (see the warning below). Keeps the pooled h2 connection warm and makes
# caddy_reverse_proxy_upstreams_healthy meaningful instead of a constant 1.
# health_uri /
# health_status 2xx
# health_interval 15s
# health_timeout 5s
# health_fails 3
transport http {
tls_server_name netbird.savorcare.com
# "2" only enables h2; it does NOT enforce it (see below).
versions 2
# TCP keepalive probes. NOT an h2 PING — Caddy exposes no h2 ping option.
keepalive_interval 15s
# NEVER set read_timeout/write_timeout here: they wrap the raw TCPConn and
# will kill idle streams.
# Do not set request_buffers/response_buffers: they break streaming.
}
}
}
```
**Two traps in the block above — read before copying it.**
* **Active health checks are a production risk on a gRPC-only vhost.** A health check is a plain `GET <health_uri>`
that must return `200` (default `health_status 200`, `health_fails 1`) or the upstream is marked down and stops
receiving traffic. Before enabling it, confirm the origin answers:
`curl -sS -o /dev/null -w '%{http_code}\n' --http2 -x '' --resolve netbird.savorcare.com:10443:$(dig +short qhxxg.savorcare.com|head -1) -H 'Host: netbird.savorcare.com' https://netbird.savorcare.com:10443/`.
If that is not a 200, either pick a URI that is, or use `health_upstream` + `health_headers`, or leave health
checks off and rely on §5 logging instead.
* **`stream_close_delay` does NOT protect plain h2 gRPC streams.** In v2.11.4 `registerConnection` is only called
from `handleUpgradeResponse` (`streaming.go:202-203`), which is only reached on
`res.StatusCode == http.StatusSwitchingProtocols` (`reverseproxy.go:1219`). A normal gRPC-over-h2 proxy never
returns 101, so it is **not** registered and `stream_close_delay` is inert for it. Config-reload teardown of live
h2 streams is therefore **unmitigated** by that directive ([#8143](https://github.com/caddyserver/caddy/issues/8143)).
`stream_close_delay` is still worth setting if you ever proxy WebSocket/`Extended CONNECT`, but do not expect it to
fix gRPC.
**`versions 2` is not "h2 only".** `http2.ConfigureTransport(rt)` appends **both** protocols to the ALPN list
(Go 1.25.1 `h2_bundle.go`, inside every Caddy binary):
```go
if !http2strSliceContains(t1.TLSClientConfig.NextProtos, "h2") {
t1.TLSClientConfig.NextProtos = append([]string{"h2"}, t1.TLSClientConfig.NextProtos...)
}
if !http2strSliceContains(t1.TLSClientConfig.NextProtos, "http/1.1") {
t1.TLSClientConfig.NextProtos = append(t1.TLSClientConfig.NextProtos, "http/1.1")
}
```
Caddy's own code only touches `rt.Protocols` for `h2c`, with the comment *"Protocols field is only used when the
request is not using TLS, http1/2 over TLS is still allowed"* (`httptransport.go`, v2.11.4). So if the origin ever
stops offering `h2` over ALPN you silently get **HTTP/1.1** to the origin — which for gRPC is fatal and for plain
HTTP/2 requests is invisible. **Verify the negotiated protocol on the Traefik side (§5).** There is no Caddy
Caddyfile knob that hard-pins h2 for a TLS upstream. **UNCONFIRMED:** no issue found requesting one.
**`lb_try_duration` / `lb_retries` — do not enable for gRPC.** Two independent reasons:
* Docs: *"**lb_retry_match** … A request must match this condition in order to be retried if the connection to the
upstream succeeded but the subsequent round-trip failed. **If the connection to the upstream failed, a retry is
always allowed.** By default, only `GET` requests are retried."* — a gRPC POST is not `GET`, so mid-flight
failures are not retried anyway; and a retry that *does* happen replays a **body that has already been consumed**.
* Bug [#8166](https://github.com/caddyserver/caddy/issues/8166): with a request-only `lb_retry_match` expression,
*"the request is sent again to the next upstreams (a POST arrives with an empty body after the first attempt),
and once the retries run out the client gets a `502`"*. Fixed only after v2.11.7.
Also: **do not set `request_buffers`** (it breaks incremental forwarding and, per the docs, an incremental request
with an explicit buffer now gets **501 Not Implemented**), and **do not set `read_timeout`/`write_timeout`**.
---
## 5. Diagnostics that will prove which candidate it is
### 5.1 Global DEBUG logging (required — the site `log` directive is not enough)
The `log` directive's `level` only affects **access** logs, and the docs state: *"Note that access logs currently
only emit INFO and ERROR level logs."* The proxy's own DEBUG lines (e.g. `"upstream roundtrip"`,
`reverseproxy.go:1057`) come from the module logger `http.handlers.reverse_proxy` under the **global** logger. So:
```
{
log {
level DEBUG
output file /var/log/caddy/caddy-debug.log
# or: output stderr (then: journalctl -u caddy -f -l)
}
}
netbird.savorcare.com {
...
log {
output file /var/log/caddy/netbird-access.log
format json
}
}
```
**Log lines to grep for, and what each one proves:**
| Line (logger / level) | Meaning |
|---|---|
| `http.handlers.reverse_proxy` **DEBUG** `"upstream roundtrip"` + `error`, `upstream`, `duration`, `request` | **The decisive line.** `reverseproxy.go:1038-1060`. The `error` string distinguishes §3.3 (`… graceful shutdown GOAWAY`, `… cannot retry err […] after Request.Body was written`, `http2: client conn not usable`, `http2: client conn could not be established`) from §3.4 (no error at all). |
| `http.handlers.reverse_proxy` **WARN** `"aborting with incomplete response"` + `error` | §3.0: the upstream died **mid-body**; Caddy then panics `ErrAbortHandler` → RST_STREAM → gRPC `Internal`. Concurrency of this with 502s proves one upstream failure, not two bugs. |
| `http.handlers.reverse_proxy` **DEBUG** `"closed response body from upstream"` | body-close error surfaced |
| `http.handlers.reverse_proxy` **DEBUG** `"wrote header"`, `"response finalized"`, `"retrying"` | ordering/timing of the failure vs. headers |
| `http.log.error` (level ERROR) with `duration` + error | handler error path, `server.go:408` |
| access log `"handled request"` (INFO/ERROR) | see below |
**Access-log fields that matter** (`server.go:895-905`): `status`, `duration`, `size`, `bytes_read`, `resp_headers`,
`user_id`, `request` (`proto`, `host`, `uri`, `headers`). Site-level `log { level DEBUG }` adds nothing.
Read it like this:
* `status: 502` + `duration` ≈ 0.0002 s → Caddy-side upstream failure, §3.3/§3.9. Cross-check the DEBUG error string.
* `status: 200` + small `size` + short `duration`, then the client still errors → the failure is **after** Caddy
handed back a good response; look for WARN `"aborting with incomplete response"`.
* `status: 200` + `duration` ≈ the client-reported lifetime → Caddy is *not* cutting the stream; the cut is on the
client↔Caddy hop (or the client's own logic).
* `status: 502` + **large** `duration` → timeouts/queueing (§3.2), not a stale-conn race.
### 5.2 Go's own HTTP/2 trace (the only place GOAWAY/RST_STREAM frames are named)
Caddy cannot log these through its own logger. Set on the **Caddy** service only, temporarily:
```
sudo systemctl edit caddy
# [Service]
# Environment=GODEBUG=http2debug=2
sudo systemctl restart caddy && journalctl -u caddy -f
```
This turns on `golang.org/x/net/http2` verbose logging to stderr, including the client-side
`"http2: Transport received GOAWAY…"`, `RoundTrip retrying after failure: …`, `"http2: Transport failed to get
client conn"` and per-frame traces. Invaluable for §3.3/§3.9. **UNCONFIRMED:** I did not exercise
`GODEBUG=http2debug=2` against a live v2.11.4 build — verify the output appears in your version before relying on it.
### 5.3 Metrics — `caddy_reverse_proxy_upstreams_healthy` exists but will NOT answer this question
The metric is documented ([caddyserver.com/docs/metrics](https://caddyserver.com/docs/metrics)): *"A gauge of the
reverse proxy upstreams healthiness. Value 0 means the upstream is unhealthy, where as 1 means the upstream is
healthy."* Scrape it from the admin endpoint: `curl -s localhost:2019/metrics | grep caddy_reverse_proxy`.
**Caveat, verified in source:** it is fed from `Upstream.Healthy()`, which is
```go
healthy := u.healthy()
if healthy && u.healthCheckPolicy != nil { healthy = u.Host.Fails() < u.healthCheckPolicy.MaxFails }
if healthy && u.cb != nil { healthy = u.cb.OK() }
```
— i.e. only passive-health-check fail counts and the circuit breaker. **With no health checks configured
(`fail_duration` / `health_uri`) and no circuit breaker it is a constant `1`.** It says nothing about TCP/h2
connection-pool state, and **there is no metric for pooled upstream connections** in Caddy (the only other
reverse-proxy metrics are stream gauges/counters: `streams_active`, `streams_total`, `stream_duration_seconds`,
`stream_bytes_total` — and those cover *upgraded* streams, not plain h2 gRPC). Enabling `health_uri` (§4) is what
makes the gauge meaningful.
### 5.4 Prove which hop and which protocol
* **Traefik access log (JSON)**: look for the requests arriving from Caddy. Check `proto`
(`HTTP/2.0` vs `HTTP/1.1` — proves whether `versions 2` really negotiated h2), `RouterName`,
`originStatus`/`OriginDuration`, and whether the gRPC request is there **at all**. If failures show **no**
Traefik-side entry while Caddy logs an `"upstream roundtrip"` error, the failure was pre-header on the origin
connection ⇒ §3.3/§3.9.
* **Traefik's static config**: `entryPoints.<name>.transport.respondingTimeouts.idleTimeout` (default 180s) vs
Caddy's `keepalive` (default 120s). And confirm `transport.keepAliveMaxRequests` / `keepAliveMaxTime` are 0 —
any non-zero value makes Traefik **send GOAWAY** by design.
* **Local socket/flow check on the Caddy host**: `ss -tin dst :10443` — one long-lived ESTABLISHED conn with a
growing `Send-Q`/`bytes_sent` means the pool is healthy; a new local port per failure means Caddy is re-dialing
every time (stale-conn churn). `sudo tcpdump -ni any port 10443 -A | grep -i goaway` if you want the frames.
* **A/B test that isolates §3.3 in one line**: set `keepalive off` (never reuse the upstream connection). If the
200 ms failures vanish, it is a pooled-connection/GOAWAY problem. If they persist, it is not.
* **A/B test that isolates the origin hop**: point Caddy at a local `h2c` echo/`grpcurl` target and stream for
minutes. If that is stable, the Caddy side is fine and the origin hop is the problem.
---
## 6. Confidence summary
**Confirmed from primary sources (docs + source I read myself):** no gRPC-specific code in `reverse_proxy` (§1.1);
`flush_interval` is ignored when `Content-Length` is unknown, so it is not needed for server-streaming gRPC (§1.2);
exact meaning/defaults of `keepalive`/`keepalive_interval`/`keepalive_idle_*` and the absence of
`keepalive_idle_timeout`/`keepalive_probe_interval` (§2.1); Caddy sets no h2 `ReadIdleTimeout`/`PingTimeout` (§2.2);
Go's retry rules and Caddy's missing `GetBody` ⇒ GOAWAY/REFUSED_STREAM can 502 instantly (§2.3); Caddy v2.11.4
server timeout defaults and the v2.11.6 change (§3.6); the `ErrAbortHandler` → RST_STREAM path explaining the two
client errors (§3.0); Go's h2 client queues instead of failing at `MAX_CONCURRENT_STREAMS`, and Go's h2 server RSTs
rather than GOAWAYs on excess streams, but **does** GOAWAY on idle (§3.2, §3.10); all Traefik defaults in §3.10;
metric semantics (§5.3).
**UNCONFIRMED / not established:**
1. Which of §3.3 / §3.5 / §3.8 / §3.9 is actually firing in this deployment — no live access, and no logs supplied.
2. Whether Traefik in this deployment has `keepAliveMaxRequests` / `keepAliveMaxTime` non-zero, or an `idleTimeout`
below 120 s. The observed ~300 s direct lifetime does not match Traefik's documented 180 s default, so something
is configured; I could not see what.
3. Whether the h2 hop Caddy→Traefik really negotiates h2 (given §4's ALPN finding).
4. Whether a dedicated Caddy issue exists for "stale pooled h2 conn → instant 502"; I searched and found none.
5. That `GODEBUG=http2debug=2` produces the expected output on a v2.11.4 build (documented Go facility; not exercised here).
6. Netbird's own client-side gRPC keepalive/deadline settings — out of scope, not inspected.
## 7. Source index
* Caddyfile `reverse_proxy`: <https://caddyserver.com/docs/caddyfile/directives/reverse_proxy> (esp. the
**Streaming**, **HTTPS** and **Transports → `http`** sections)
* Caddy JSON config docs — **note:** `https://caddyserver.com/docs/json/apps/http/servers/routes/handle/reverse_proxy/`
(and its `/transport/http/` child) renders the *"This module does not come with Caddy"* fallback and contains **no**
field documentation. I confirmed the same for `.../handle/file_server/` and `.../handle/subroute/`, so this is a
site-wide gap for built-in modules, not a bad URL. Use the Caddyfile page + source instead.
* Caddy global options (`log`, `servers.timeouts`): <https://caddyserver.com/docs/caddyfile/options>
* Caddy `log` directive: <https://caddyserver.com/docs/caddyfile/directives/log>
* Caddy metrics: <https://caddyserver.com/docs/metrics>
* Caddy source (pinned `v2.11.4`): `modules/caddyhttp/reverseproxy/{reverseproxy.go,streaming.go,httptransport.go,caddyfile.go,metrics.go,hosts.go}`,
`modules/caddyhttp/{matchers.go,server.go,app.go,headers/headers.go}`
* Traefik EntryPoints: <https://doc.traefik.io/traefik/routing/entrypoints/>
* Traefik ServersTransport: <https://doc.traefik.io/traefik/reference/routing-configuration/http/load-balancing/serverstransport/>
* Go `src/net/http/h2_bundle.go` (1.25.1) and `golang.org/x/net/http2/server.go`