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

  1. Verify egress connectivity to the upstream: curl -v https://upstream:port from the Caddy host
  2. Increase tls_handshake_timeout in the transport if the network is legitimately high-latency
  3. Check upstream TLS health (certificate expiry, overload) — handshakes that stall often mean a dead backend
  4. 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

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

Related errors


AI-assisted analysis of caddyserver/caddy@50e54ee279 (2026-08-15). Data as JSON: /api/errors/e457d199b70fdb04. Report an issue: GitHub.