caddyserver/caddy · error
HTTP transport TLS handshake %ds timeout
Error message
HTTP transport TLS handshake %ds timeout
What it means
Not thrown by itself: it is a timeout *cause* attached via `context.WithTimeoutCause` around `tlsConn.HandshakeContext` in the custom DialTLSContext path (used when ServerName has placeholders or proxy_protocol is on). If the upstream TLS handshake exceeds tls_handshake_timeout, the returned error unwraps to this cause string (`%ds` is the configured seconds).
Source
Thrown at modules/caddyhttp/reverseproxy/httptransport.go:453
tlsConfig := rt.TLSClientConfig.Clone()
if serverNameHasPlaceholder {
repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
tlsConfig.ServerName = repl.ReplaceAll(tlsConfig.ServerName, "")
}
// h1 only
if caddyhttp.GetVar(ctx, tlsH1OnlyVarKey) == true {
// stdlib does this
// https://github.com/golang/go/blob/4837fbe4145cd47b43eed66fee9eed9c2b988316/src/net/http/transport.go#L1701
tlsConfig.NextProtos = nil
}
tlsConn := tls.Client(conn, tlsConfig)
// complete the handshake before returning the connection
if rt.TLSHandshakeTimeout != 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeoutCause(ctx, rt.TLSHandshakeTimeout, fmt.Errorf("HTTP transport TLS handshake %ds timeout", int(rt.TLSHandshakeTimeout.Seconds())))
defer cancel()
}
err = tlsConn.HandshakeContext(ctx)
if err != nil {
_ = tlsConn.Close()
return nil, err
}
return tlsConn, nil
}
}
}
if h.KeepAlive != nil {
// according to https://pkg.go.dev/net#Dialer.KeepAliveConfig,
// KeepAlive is ignored if KeepAliveConfig.Enable is true.
// If configured to 0, a system-dependent default is used.
// To disable tcp keepalive, choose a negative value,
// so KeepAliveConfig.Enable is false and KeepAlive is negative.View on GitHub (pinned to 50e54ee279)
Solutions
- Verify egress connectivity to the upstream: curl -v https://upstream:port from the Caddy host
- Increase tls_handshake_timeout in the transport if the network is legitimately high-latency
- Check upstream TLS health (certificate expiry, overload) — handshakes that stall often mean a dead backend
- Ensure the security group/firewall allows outbound TCP to the upstream port
Example fix
# before
transport http {
tls_handshake_timeout 2s
}
# after
transport http {
tls_handshake_timeout 10s
} Defensive patterns
Strategy: retry
Validate before calling
// preflight upstream reachability before config rollout
conn, err := net.DialTimeout("tcp", upstream, 5*time.Second)
if err != nil {
log.Printf("upstream unreachable: %v", err)
}
conn.Close() Try / catch
// errors.Is via context cause
err := respErr
var cause interface{ Timeout() bool }
if errors.As(err, &cause) || strings.Contains(err.Error(), "TLS handshake") {
// treat as transient: retry next upstream / mark unhealthy
proxy.MarkUnhealthy(upstream)
} Prevention
- Open egress firewall rules to upstream ports before enabling proxy_protocol/placeholder SNI configs
- Size tls_handshake_timeout to your worst-case RTT plus cert-chain cost
- Monitor handshake-failure rates per upstream to catch dead backends early
When it happens
Trigger: Upstream TLS server unreachable/hanging (firewall drops SYN-ACK, blackholed network), TLS handshake storms with a slow upstream, or tls_handshake_timeout set too low for high-latency links. Happens only when DialTLSContext customization is active (placeholder in tls_server_name or proxy_protocol enabled).
Common situations: Cloud firewall/security-group blocking the proxy's egress on 443; upstream behind a slow link; mTLS where the server requests and waits on client certs; timeouts triggered during upstream restarts.
Understand the failure class
- SSL/TLS and certificate errors — how TLS handshakes and certificate validation fail.
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Related errors
- problem calling http loader url: %v
- upstream address has conflicting scheme (http://) and port (
- upstream address has conflicting scheme (https://) and port
- local_address must be a single address, not a port range
- local_address must be a TCP address, not a UDP address
AI-assisted analysis of caddyserver/caddy@50e54ee279 (2026-08-15).
Data as JSON: /api/errors/e457d199b70fdb04.
Report an issue: GitHub.