detached ccc19f7

Merge pull request 'feat(traffic): configurable HTTP timeouts with an inactivity deadline' (#20) from feat/configurable-http-timeouts into main

commit
ccc19f704eb08851e7fc57cdbe74bafa5fc30c5f
author
bnema · · 2026-10-16 21:09 UTC
merge of
4fc01fa + 2565526 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
19 files · +1685 -39
M docs/config/index.md +9 -0
@@ -156,6 +156,13 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G
156156 | `server.data_dir` | `~/.gordon` |
157157 | `server.max_blob_chunk_size` | `"95MB"` |
158158 | `server.max_blob_size` | `"1GB"` |
159+| `server.http.read_header_timeout` | `"10s"` |
160+| `server.http.read_timeout` | `"5m"` |
161+| `server.http.write_timeout` | `"5m"` |
162+| `server.http.idle_timeout` | `"120s"` |
163+| `traffic.http.read_header_timeout` | `"5s"` |
164+| `traffic.http.idle_timeout` | `"30s"` |
165+| `traffic.http.transfer_idle_timeout` | `"2m"` |
159166 | `auth.enabled` | `true` |
160167 | `auth.secrets_backend` | `"unsafe"` |
161168 | `auth.token_expiry` | `"30d"` |
@@ -210,6 +217,7 @@ gordon daemon reload
210217 | `server.max_proxy_body_size` |
211218 | `server.max_proxy_response_size` |
212219 | `server.max_concurrent_conns` |
220+| `traffic.http.*` (new connections after the traffic graph is applied) |
213221
214222
215223 ### Requires restart
@@ -220,6 +228,7 @@ gordon daemon reload
220228 | `server.data_dir` |
221229 | `server.max_blob_chunk_size` |
222230 | `server.max_blob_size` |
231+| `server.http.*` |
223232 | `auth.*` |
224233
225234 ## Environment Variable Override
@@ -156,6 +156,13 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G
156 | `server.data_dir` | `~/.gordon` |156 | `server.data_dir` | `~/.gordon` |
157 | `server.max_blob_chunk_size` | `"95MB"` |157 | `server.max_blob_chunk_size` | `"95MB"` |
158 | `server.max_blob_size` | `"1GB"` |158 | `server.max_blob_size` | `"1GB"` |
159+| `server.http.read_header_timeout` | `"10s"` |
160+| `server.http.read_timeout` | `"5m"` |
161+| `server.http.write_timeout` | `"5m"` |
162+| `server.http.idle_timeout` | `"120s"` |
163+| `traffic.http.read_header_timeout` | `"5s"` |
164+| `traffic.http.idle_timeout` | `"30s"` |
165+| `traffic.http.transfer_idle_timeout` | `"2m"` |
159 | `auth.enabled` | `true` |166 | `auth.enabled` | `true` |
160 | `auth.secrets_backend` | `"unsafe"` |167 | `auth.secrets_backend` | `"unsafe"` |
161 | `auth.token_expiry` | `"30d"` |168 | `auth.token_expiry` | `"30d"` |
@@ -210,6 +217,7 @@ gordon daemon reload
210 | `server.max_proxy_body_size` |217 | `server.max_proxy_body_size` |
211 | `server.max_proxy_response_size` |218 | `server.max_proxy_response_size` |
212 | `server.max_concurrent_conns` |219 | `server.max_concurrent_conns` |
220+| `traffic.http.*` (new connections after the traffic graph is applied) |
213 221
214 222
215 ### Requires restart223 ### Requires restart
@@ -220,6 +228,7 @@ gordon daemon reload
220 | `server.data_dir` |228 | `server.data_dir` |
221 | `server.max_blob_chunk_size` |229 | `server.max_blob_chunk_size` |
222 | `server.max_blob_size` |230 | `server.max_blob_size` |
231+| `server.http.*` |
223 | `auth.*` |232 | `auth.*` |
224 233
225 ## Environment Variable Override234 ## Environment Variable Override
M docs/config/reference.md +18 -0
@@ -21,6 +21,12 @@ registry_allowed_ips = [] # IPs or CIDR ranges allowed to acc
2121 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach HTTP proxy paths (empty = allow all, e.g. Cloudflare IPs)
2222 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
2323
24+[server.http] # Registry HTTP server timeouts (Go durations)
25+read_header_timeout = "10s" # Max time to read request headers
26+read_timeout = "5m" # Max time to read a whole request
27+write_timeout = "5m" # Max time to write a whole response
28+idle_timeout = "120s" # Keep-alive idle timeout
29+
2430 # =============================================================================
2531 # ENTRYPOINTS
2632 # =============================================================================
@@ -32,6 +38,11 @@ trusted_cidrs = [] # Peer socket IP allowlist for all
3238 # raw_fallback_trusted_cidrs = ["100.64.0.0/10"]
3339 # allow_public_raw_fallback = false
3440
41+[traffic.http] # HTTP servers behind entrypoints (apps + admin API)
42+read_header_timeout = "5s" # Max time to read request headers
43+idle_timeout = "30s" # Keep-alive idle timeout
44+transfer_idle_timeout = "2m" # Cut a transfer only after this long without data (no fixed read/write timeout)
45+
3546 # =============================================================================
3647 # DNS
3748 # =============================================================================
@@ -241,6 +252,13 @@ keep_last = 3 # Keep N newest tags per repository
241252 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
242253 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
243254 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
255+| `server.http.read_header_timeout` | `"10s"` | Registry servers: max time to read request headers (env `GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT`) |
256+| `server.http.read_timeout` | `"5m"` | Registry servers: max time to read a whole request (env `GORDON_SERVER_HTTP_READ_TIMEOUT`) |
257+| `server.http.write_timeout` | `"5m"` | Registry servers: max time to write a whole response (env `GORDON_SERVER_HTTP_WRITE_TIMEOUT`) |
258+| `server.http.idle_timeout` | `"120s"` | Registry servers: keep-alive idle timeout (env `GORDON_SERVER_HTTP_IDLE_TIMEOUT`) |
259+| `traffic.http.read_header_timeout` | `"5s"` | Entrypoint HTTP servers: max time to read request headers (env `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT`) |
260+| `traffic.http.idle_timeout` | `"30s"` | Entrypoint HTTP servers: keep-alive idle timeout (env `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT`) |
261+| `traffic.http.transfer_idle_timeout` | `"2m"` | Entrypoint HTTP servers: inactivity timeout for request bodies and responses (env `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT`) |
244262 | `entrypoints.<name>.address` | none | Deployment-selected listen address; `edge` is conventional for route-capable entrypoints but is not required when exactly one `smart_tcp` or `tls_mux` entrypoint exists |
245263 | `entrypoints.<name>.protocol` | none | Entrypoint protocol: `smart_tcp`, `tls_mux`, `tcp`, or `udp` |
246264 | `entrypoints.<name>.trusted_cidrs` | `[]` | Peer socket IP allowlist for all traffic on the entrypoint |
@@ -21,6 +21,12 @@ registry_allowed_ips = [] # IPs or CIDR ranges allowed to acc
21 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach HTTP proxy paths (empty = allow all, e.g. Cloudflare IPs)21 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach HTTP proxy paths (empty = allow all, e.g. Cloudflare IPs)
22 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)22 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
23 23
24+[server.http] # Registry HTTP server timeouts (Go durations)
25+read_header_timeout = "10s" # Max time to read request headers
26+read_timeout = "5m" # Max time to read a whole request
27+write_timeout = "5m" # Max time to write a whole response
28+idle_timeout = "120s" # Keep-alive idle timeout
29+
24 # =============================================================================30 # =============================================================================
25 # ENTRYPOINTS31 # ENTRYPOINTS
26 # =============================================================================32 # =============================================================================
@@ -32,6 +38,11 @@ trusted_cidrs = [] # Peer socket IP allowlist for all
32 # raw_fallback_trusted_cidrs = ["100.64.0.0/10"]38 # raw_fallback_trusted_cidrs = ["100.64.0.0/10"]
33 # allow_public_raw_fallback = false39 # allow_public_raw_fallback = false
34 40
41+[traffic.http] # HTTP servers behind entrypoints (apps + admin API)
42+read_header_timeout = "5s" # Max time to read request headers
43+idle_timeout = "30s" # Keep-alive idle timeout
44+transfer_idle_timeout = "2m" # Cut a transfer only after this long without data (no fixed read/write timeout)
45+
35 # =============================================================================46 # =============================================================================
36 # DNS47 # DNS
37 # =============================================================================48 # =============================================================================
@@ -241,6 +252,13 @@ keep_last = 3 # Keep N newest tags per repository
241 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |252 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
242 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |253 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
243 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |254 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
255+| `server.http.read_header_timeout` | `"10s"` | Registry servers: max time to read request headers (env `GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT`) |
256+| `server.http.read_timeout` | `"5m"` | Registry servers: max time to read a whole request (env `GORDON_SERVER_HTTP_READ_TIMEOUT`) |
257+| `server.http.write_timeout` | `"5m"` | Registry servers: max time to write a whole response (env `GORDON_SERVER_HTTP_WRITE_TIMEOUT`) |
258+| `server.http.idle_timeout` | `"120s"` | Registry servers: keep-alive idle timeout (env `GORDON_SERVER_HTTP_IDLE_TIMEOUT`) |
259+| `traffic.http.read_header_timeout` | `"5s"` | Entrypoint HTTP servers: max time to read request headers (env `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT`) |
260+| `traffic.http.idle_timeout` | `"30s"` | Entrypoint HTTP servers: keep-alive idle timeout (env `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT`) |
261+| `traffic.http.transfer_idle_timeout` | `"2m"` | Entrypoint HTTP servers: inactivity timeout for request bodies and responses (env `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT`) |
244 | `entrypoints.<name>.address` | none | Deployment-selected listen address; `edge` is conventional for route-capable entrypoints but is not required when exactly one `smart_tcp` or `tls_mux` entrypoint exists |262 | `entrypoints.<name>.address` | none | Deployment-selected listen address; `edge` is conventional for route-capable entrypoints but is not required when exactly one `smart_tcp` or `tls_mux` entrypoint exists |
245 | `entrypoints.<name>.protocol` | none | Entrypoint protocol: `smart_tcp`, `tls_mux`, `tcp`, or `udp` |263 | `entrypoints.<name>.protocol` | none | Entrypoint protocol: `smart_tcp`, `tls_mux`, `tcp`, or `udp` |
246 | `entrypoints.<name>.trusted_cidrs` | `[]` | Peer socket IP allowlist for all traffic on the entrypoint |264 | `entrypoints.<name>.trusted_cidrs` | `[]` | Peer socket IP allowlist for all traffic on the entrypoint |
M docs/config/server.md +21 -0
@@ -50,6 +50,27 @@ protocol = "smart_tcp"
5050
5151 `entrypoints.edge.address` is not an HTTP or HTTPS port. It is the TCP address Gordon sniffs for cleartext HTTP, h2c, TLS passthrough, normal HTTPS fallback, or explicit raw TCP fallback. Gordon does not provide a built-in default public port; choose `:443`, a high port such as `:9000`, or container/firewall mappings to match your deployment.
5252
53+## Registry HTTP Timeouts
54+
55+The registry listeners (`registry_port`, plus the loopback listener used for the internal registry) apply fixed per-connection timeouts. Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads.
56+
57+```toml
58+[server.http]
59+read_header_timeout = "10s"
60+read_timeout = "5m"
61+write_timeout = "5m"
62+idle_timeout = "120s"
63+```
64+
65+| Option | Env | Default | Description |
66+|--------|-----|---------|-------------|
67+| `server.http.read_header_timeout` | `GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT` | `"10s"` | Maximum time to read request headers |
68+| `server.http.read_timeout` | `GORDON_SERVER_HTTP_READ_TIMEOUT` | `"5m"` | Maximum time to read the whole request, body included |
69+| `server.http.write_timeout` | `GORDON_SERVER_HTTP_WRITE_TIMEOUT` | `"5m"` | Maximum time to write the whole response |
70+| `server.http.idle_timeout` | `GORDON_SERVER_HTTP_IDLE_TIMEOUT` | `"120s"` | Maximum keep-alive idle time between requests |
71+
72+`read_timeout` and `write_timeout` bound a whole registry request or response, so a layer transfer longer than these values is cut even while data flows. Raise them for very large layers over slow links. Traffic behind entrypoints (apps and the admin API) uses an inactivity timeout instead; see [Traffic HTTP Timeouts](./traffic.md#http-timeouts). The owner-only local admin socket has no write timeout so log streaming stays open. Changes require a restart.
73+
5374 ## Internal CA and TLS
5475
5576 Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`.
@@ -50,6 +50,27 @@ protocol = "smart_tcp"
50 50
51 `entrypoints.edge.address` is not an HTTP or HTTPS port. It is the TCP address Gordon sniffs for cleartext HTTP, h2c, TLS passthrough, normal HTTPS fallback, or explicit raw TCP fallback. Gordon does not provide a built-in default public port; choose `:443`, a high port such as `:9000`, or container/firewall mappings to match your deployment.51 `entrypoints.edge.address` is not an HTTP or HTTPS port. It is the TCP address Gordon sniffs for cleartext HTTP, h2c, TLS passthrough, normal HTTPS fallback, or explicit raw TCP fallback. Gordon does not provide a built-in default public port; choose `:443`, a high port such as `:9000`, or container/firewall mappings to match your deployment.
52 52
53+## Registry HTTP Timeouts
54+
55+The registry listeners (`registry_port`, plus the loopback listener used for the internal registry) apply fixed per-connection timeouts. Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads.
56+
57+```toml
58+[server.http]
59+read_header_timeout = "10s"
60+read_timeout = "5m"
61+write_timeout = "5m"
62+idle_timeout = "120s"
63+```
64+
65+| Option | Env | Default | Description |
66+|--------|-----|---------|-------------|
67+| `server.http.read_header_timeout` | `GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT` | `"10s"` | Maximum time to read request headers |
68+| `server.http.read_timeout` | `GORDON_SERVER_HTTP_READ_TIMEOUT` | `"5m"` | Maximum time to read the whole request, body included |
69+| `server.http.write_timeout` | `GORDON_SERVER_HTTP_WRITE_TIMEOUT` | `"5m"` | Maximum time to write the whole response |
70+| `server.http.idle_timeout` | `GORDON_SERVER_HTTP_IDLE_TIMEOUT` | `"120s"` | Maximum keep-alive idle time between requests |
71+
72+`read_timeout` and `write_timeout` bound a whole registry request or response, so a layer transfer longer than these values is cut even while data flows. Raise them for very large layers over slow links. Traffic behind entrypoints (apps and the admin API) uses an inactivity timeout instead; see [Traffic HTTP Timeouts](./traffic.md#http-timeouts). The owner-only local admin socket has no write timeout so log streaming stays open. Changes require a restart.
73+
53 ## Internal CA and TLS74 ## Internal CA and TLS
54 75
55 Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`.76 Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`.
M docs/config/traffic.md +31 -0
@@ -127,6 +127,37 @@ allow_public_raw_fallback = true
127127
128128 `trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`.
129129
130+## HTTP Timeouts
131+
132+The HTTP servers behind `smart_tcp` and `tls_mux` entrypoints serve apps and the admin API. They do not use a fixed read or write timeout, so a download or upload that keeps moving data is never cut for taking too long. A connection is cut only after `transfer_idle_timeout` passes with no data in the direction being waited on.
133+
134+```toml
135+[traffic.http]
136+read_header_timeout = "5s"
137+idle_timeout = "30s"
138+transfer_idle_timeout = "2m"
139+```
140+
141+| Option | Env | Default | Description |
142+|--------|-----|---------|-------------|
143+| `traffic.http.read_header_timeout` | `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT` | `"5s"` | Maximum time to read request headers |
144+| `traffic.http.idle_timeout` | `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT` | `"30s"` | Maximum keep-alive idle time between requests |
145+| `traffic.http.transfer_idle_timeout` | `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT` | `"2m"` | Inactivity timeout for request bodies and responses |
146+
147+Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads. Servers pick up changes when the traffic graph is next applied (reload); connections already in flight keep their previous timeout.
148+
149+### Inactivity timeout
150+
151+Each request has an inactivity timer, armed when the request starts. Progress resets it: a response chunk accepted by the client connection (8 KiB granularity), a flush, or request body bytes read. If `transfer_idle_timeout` passes without progress, Gordon cancels the request context, which aborts the upstream request of a reverse-proxied app, and closes the client connection. When the handler returns, the final response flush and the drain of an unread request body are bounded by the same duration. Consequences:
152+
153+- a response that streams data at least once per `transfer_idle_timeout` can last arbitrarily long, and a client that keeps reading is never cut (it must accept 8 KiB per `transfer_idle_timeout`, about 68 B/s at the 2 minute default);
154+- an upload whose body arrives at least once per `transfer_idle_timeout` can last arbitrarily long;
155+- a backend that stays silent longer than `transfer_idle_timeout` before or between response chunks has its connection cut, so set it above the slowest expected backend response time;
156+- the timer also fires when a handler or backend is silent and no write is pending;
157+- WebSocket and other upgraded connections are not subject to it, and HTTP/2 streams are handled the same way as HTTP/1.
158+
159+`transfer_idle_timeout` does not apply to `[traffic.tcp]` or `[traffic.udp]` routers; those use their own `idle_timeout` (see below).
160+
130161 ## TCP Routers
131162
132163 Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`.
@@ -127,6 +127,37 @@ allow_public_raw_fallback = true
127 127
128 `trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`.128 `trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`.
129 129
130+## HTTP Timeouts
131+
132+The HTTP servers behind `smart_tcp` and `tls_mux` entrypoints serve apps and the admin API. They do not use a fixed read or write timeout, so a download or upload that keeps moving data is never cut for taking too long. A connection is cut only after `transfer_idle_timeout` passes with no data in the direction being waited on.
133+
134+```toml
135+[traffic.http]
136+read_header_timeout = "5s"
137+idle_timeout = "30s"
138+transfer_idle_timeout = "2m"
139+```
140+
141+| Option | Env | Default | Description |
142+|--------|-----|---------|-------------|
143+| `traffic.http.read_header_timeout` | `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT` | `"5s"` | Maximum time to read request headers |
144+| `traffic.http.idle_timeout` | `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT` | `"30s"` | Maximum keep-alive idle time between requests |
145+| `traffic.http.transfer_idle_timeout` | `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT` | `"2m"` | Inactivity timeout for request bodies and responses |
146+
147+Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads. Servers pick up changes when the traffic graph is next applied (reload); connections already in flight keep their previous timeout.
148+
149+### Inactivity timeout
150+
151+Each request has an inactivity timer, armed when the request starts. Progress resets it: a response chunk accepted by the client connection (8 KiB granularity), a flush, or request body bytes read. If `transfer_idle_timeout` passes without progress, Gordon cancels the request context, which aborts the upstream request of a reverse-proxied app, and closes the client connection. When the handler returns, the final response flush and the drain of an unread request body are bounded by the same duration. Consequences:
152+
153+- a response that streams data at least once per `transfer_idle_timeout` can last arbitrarily long, and a client that keeps reading is never cut (it must accept 8 KiB per `transfer_idle_timeout`, about 68 B/s at the 2 minute default);
154+- an upload whose body arrives at least once per `transfer_idle_timeout` can last arbitrarily long;
155+- a backend that stays silent longer than `transfer_idle_timeout` before or between response chunks has its connection cut, so set it above the slowest expected backend response time;
156+- the timer also fires when a handler or backend is silent and no write is pending;
157+- WebSocket and other upgraded connections are not subject to it, and HTTP/2 streams are handled the same way as HTTP/1.
158+
159+`transfer_idle_timeout` does not apply to `[traffic.tcp]` or `[traffic.udp]` routers; those use their own `idle_timeout` (see below).
160+
130 ## TCP Routers161 ## TCP Routers
131 162
132 Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`.163 Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`.
A internal/adapters/in/http/middleware/transfer_idle.go +282 -0
@@ -0,0 +1,282 @@
1+package middleware
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "io"
8+ "net"
9+ "net/http"
10+ "sync"
11+ "time"
12+)
13+
14+// errTransferFinished is returned by writer calls made after the request
15+// finished (or was hijacked). The controller is never touched in that state.
16+var errTransferFinished = errors.New("transfer idle: request already finished")
17+
18+// idleWriteChunk is the progress unit of response writes. Each chunk is handed
19+// to the connection separately and counts as progress once it has been fully
20+// accepted, so a client that keeps consuming data is never cut while a client
21+// that stops reading is cut after the idle timeout. The residual granularity is
22+// one chunk: a client must accept idleWriteChunk bytes within one idle period,
23+// which is a floor of idleWriteChunk/idle bytes per second (about 68 B/s for
24+// the 2 minute default).
25+const idleWriteChunk = 8 * 1024
26+
27+// TransferIdleTimeout returns a middleware that replaces fixed per-request
28+// read/write timeouts with a request-scoped inactivity timeout. The server it
29+// wraps must not set ReadTimeout or WriteTimeout.
30+//
31+// A single timer covers the request. Progress renews it: a response chunk
32+// accepted by the connection, a flush, or request body bytes read. When idle
33+// elapses without progress, the middleware cancels the request context (so a
34+// ReverseProxy aborts its upstream request) and expires the connection's read
35+// and write deadlines, which unblocks pending I/O and makes the server drop the
36+// connection once the handler returns. The timer also covers a handler that is
37+// silent and not blocked in any I/O, which a plain write deadline cannot.
38+//
39+// The timer is armed when the request starts, so the wait for the first
40+// response byte is bounded too. It is stopped when the handler returns and when
41+// the connection is hijacked: upgraded streams (WebSocket) are exempt.
42+// Deadlines go through http.ResponseController, so HTTP/1.x and HTTP/2 both
43+// work; Flush, Hijack and Unwrap are preserved.
44+//
45+// net/http does work after the handler returns that can block on a stalled
46+// client: the final response flush and the drain of an unread request body.
47+// With no server Read/WriteTimeout those would hang forever, so finish arms
48+// one last bounded read and write deadline (idle from now) before the handler
49+// returns and leaves it in place. net/http clears the write deadline itself
50+// after the response is finished and replaces the read deadline with the
51+// keep-alive idle / header deadline before the next request, so nothing leaks
52+// into the next request. The timer never touches the controller after return.
53+//
54+// Limits: a handler that ignores the canceled context and never touches the
55+// connection again keeps its HTTP/1 connection until it returns. A handler that
56+// enables HTTP/1 full-duplex mode (ResponseController.EnableFullDuplex) is not
57+// covered for the unread-body drain: net/http clears the read deadline before
58+// that drain, so such a handler must consume or bound its request body itself.
59+// A non-positive idle disables the middleware.
60+func TransferIdleTimeout(idle time.Duration) func(http.Handler) http.Handler {
61+ return func(next http.Handler) http.Handler {
62+ if idle <= 0 {
63+ return next
64+ }
65+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+ ctx, cancel := context.WithCancel(r.Context())
67+ hasBody := r.Body != nil && r.Body != http.NoBody
68+ state := newIdleState(idle, http.NewResponseController(w), cancel, hasBody)
69+ // finish marks the state done under the mutex before this
70+ // function returns, so nothing touches the controller afterwards.
71+ defer state.finish()
72+
73+ req := r.WithContext(ctx)
74+ if hasBody {
75+ req.Body = &idleBody{ReadCloser: r.Body, state: state}
76+ }
77+ next.ServeHTTP(&idleResponseWriter{ResponseWriter: w, state: state}, req)
78+ })
79+ }
80+}
81+
82+// idleState is the request-lifetime state shared by the writer, the body and
83+// the timer. The controller is only used while the state is live, under mu.
84+type idleState struct {
85+ idle time.Duration
86+ rc *http.ResponseController
87+ cancel context.CancelFunc
88+
89+ hasBody bool // request has a body that net/http may still have to drain
90+
91+ mu sync.Mutex
92+ timer *time.Timer
93+ last time.Time
94+ done bool // handler returned or connection hijacked
95+ expired bool // the timer fired and expired the connection deadlines
96+ hijacked bool
97+}
98+
99+func newIdleState(idle time.Duration, rc *http.ResponseController, cancel context.CancelFunc, hasBody bool) *idleState {
100+ s := &idleState{idle: idle, rc: rc, cancel: cancel, last: time.Now(), hasBody: hasBody}
101+ s.mu.Lock()
102+ s.timer = time.AfterFunc(idle, s.onTimer)
103+ s.mu.Unlock()
104+ return s
105+}
106+
107+// progress records activity. It never touches the controller.
108+func (s *idleState) progress() {
109+ s.mu.Lock()
110+ if !s.done {
111+ s.last = time.Now()
112+ }
113+ s.mu.Unlock()
114+}
115+
116+// live reports whether the request is still being served.
117+func (s *idleState) live() bool {
118+ s.mu.Lock()
119+ defer s.mu.Unlock()
120+ return !s.done
121+}
122+
123+// onTimer runs on timer expiry. Progress only stamps s.last, so the timer is
124+// re-armed for the remainder instead of being reset on every event; this also
125+// makes progress racing with expiry safe.
126+func (s *idleState) onTimer() {
127+ s.mu.Lock()
128+ defer s.mu.Unlock()
129+ if s.done {
130+ return
131+ }
132+ if remaining := s.idle - time.Since(s.last); remaining > 0 {
133+ s.timer.Reset(remaining)
134+ return
135+ }
136+ s.expired = true
137+ s.cancel()
138+ // Held under mu so finish() cannot complete while these run.
139+ now := time.Now()
140+ _ = s.rc.SetWriteDeadline(now)
141+ _ = s.rc.SetReadDeadline(now)
142+}
143+
144+// finish ends the request lifetime. After it returns, no code path calls the
145+// controller. It is safe to call more than once. Unless the connection was
146+// hijacked or already expired, it first arms bounded deadlines so net/http's
147+// post-handler work (final flush, unread body drain) cannot block forever.
148+func (s *idleState) finish() {
149+ s.mu.Lock()
150+ wasDone := s.done
151+ s.done = true
152+ s.timer.Stop()
153+ if !wasDone && !s.expired && !s.hijacked {
154+ deadline := time.Now().Add(s.idle)
155+ _ = s.rc.SetWriteDeadline(deadline)
156+ if s.hasBody {
157+ _ = s.rc.SetReadDeadline(deadline)
158+ }
159+ }
160+ s.mu.Unlock()
161+ s.cancel()
162+}
163+
164+// flush flushes through the controller unless the request finished. The mutex
165+// is not held during the flush: it may block on a slow client, and the timer
166+// callback needs the mutex to expire the deadlines that unblock it. net/http
167+// forbids using the writer after the handler returned or concurrently with its
168+// return, so the live check is a guard against misuse, not a synchronisation
169+// point for the flush itself.
170+func (s *idleState) flush() error {
171+ if !s.live() {
172+ return errTransferFinished
173+ }
174+ return s.rc.Flush()
175+}
176+
177+// hijack hijacks the connection without holding the mutex: HTTP/1 Hijack may
178+// flush pending headers and block on a client that does not read, and the timer
179+// needs the mutex to expire the deadlines that unblock it. Enforcement stays
180+// active until the hijack succeeds; only then is the timer stopped. If the
181+// timer fired meanwhile, the deadlines it set on the connection are cleared so
182+// the upgraded stream is not cut.
183+func (s *idleState) hijack() (net.Conn, *bufio.ReadWriter, error) {
184+ if !s.live() {
185+ return nil, nil, errTransferFinished
186+ }
187+ conn, rw, err := s.rc.Hijack()
188+ if err != nil {
189+ return conn, rw, err
190+ }
191+ s.mu.Lock()
192+ s.done = true
193+ s.hijacked = true
194+ s.timer.Stop()
195+ expired := s.expired
196+ s.mu.Unlock()
197+ if expired {
198+ _ = conn.SetDeadline(time.Time{})
199+ }
200+ return conn, rw, nil
201+}
202+
203+// idleResponseWriter reports response progress.
204+type idleResponseWriter struct {
205+ http.ResponseWriter
206+ state *idleState
207+}
208+
209+// Write hands p to the connection in idleWriteChunk pieces and records progress
210+// after each piece.
211+func (w *idleResponseWriter) Write(p []byte) (int, error) {
212+ if !w.state.live() {
213+ return 0, errTransferFinished
214+ }
215+ if len(p) == 0 {
216+ return w.ResponseWriter.Write(p)
217+ }
218+ written := 0
219+ for written < len(p) {
220+ chunk := p[written:min(written+idleWriteChunk, len(p))]
221+ n, err := w.ResponseWriter.Write(chunk)
222+ written += n
223+ if n > 0 {
224+ w.state.progress()
225+ }
226+ if err != nil {
227+ return written, err
228+ }
229+ }
230+ return written, nil
231+}
232+
233+// FlushError flushes and records progress. It lets http.ResponseController
234+// report flush failures.
235+func (w *idleResponseWriter) FlushError() error {
236+ err := w.state.flush()
237+ if err == nil {
238+ w.state.progress()
239+ }
240+ return err
241+}
242+
243+// Flush implements http.Flusher.
244+func (w *idleResponseWriter) Flush() {
245+ _ = w.FlushError()
246+}
247+
248+// Hijack implements http.Hijacker and stops the inactivity timer: upgraded
249+// streams (e.g. WebSocket) are exempt.
250+func (w *idleResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
251+ return w.state.hijack()
252+}
253+
254+// Unwrap exposes the underlying writer to http.ResponseController.
255+func (w *idleResponseWriter) Unwrap() http.ResponseWriter {
256+ return w.ResponseWriter
257+}
258+
259+// idleBody reports request body progress. It only records timestamps, so it is
260+// safe to Read or Close after the handler returned (the HTTP/2 transport closes
261+// the outbound body asynchronously).
262+type idleBody struct {
263+ io.ReadCloser
264+ state *idleState
265+
266+ closeOnce sync.Once
267+ closeErr error
268+}
269+
270+func (b *idleBody) Read(p []byte) (int, error) {
271+ n, err := b.ReadCloser.Read(p)
272+ if n > 0 {
273+ b.state.progress()
274+ }
275+ return n, err
276+}
277+
278+// Close is idempotent.
279+func (b *idleBody) Close() error {
280+ b.closeOnce.Do(func() { b.closeErr = b.ReadCloser.Close() })
281+ return b.closeErr
282+}
@@ -0,0 +1,282 @@
1+package middleware
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "io"
8+ "net"
9+ "net/http"
10+ "sync"
11+ "time"
12+)
13+
14+// errTransferFinished is returned by writer calls made after the request
15+// finished (or was hijacked). The controller is never touched in that state.
16+var errTransferFinished = errors.New("transfer idle: request already finished")
17+
18+// idleWriteChunk is the progress unit of response writes. Each chunk is handed
19+// to the connection separately and counts as progress once it has been fully
20+// accepted, so a client that keeps consuming data is never cut while a client
21+// that stops reading is cut after the idle timeout. The residual granularity is
22+// one chunk: a client must accept idleWriteChunk bytes within one idle period,
23+// which is a floor of idleWriteChunk/idle bytes per second (about 68 B/s for
24+// the 2 minute default).
25+const idleWriteChunk = 8 * 1024
26+
27+// TransferIdleTimeout returns a middleware that replaces fixed per-request
28+// read/write timeouts with a request-scoped inactivity timeout. The server it
29+// wraps must not set ReadTimeout or WriteTimeout.
30+//
31+// A single timer covers the request. Progress renews it: a response chunk
32+// accepted by the connection, a flush, or request body bytes read. When idle
33+// elapses without progress, the middleware cancels the request context (so a
34+// ReverseProxy aborts its upstream request) and expires the connection's read
35+// and write deadlines, which unblocks pending I/O and makes the server drop the
36+// connection once the handler returns. The timer also covers a handler that is
37+// silent and not blocked in any I/O, which a plain write deadline cannot.
38+//
39+// The timer is armed when the request starts, so the wait for the first
40+// response byte is bounded too. It is stopped when the handler returns and when
41+// the connection is hijacked: upgraded streams (WebSocket) are exempt.
42+// Deadlines go through http.ResponseController, so HTTP/1.x and HTTP/2 both
43+// work; Flush, Hijack and Unwrap are preserved.
44+//
45+// net/http does work after the handler returns that can block on a stalled
46+// client: the final response flush and the drain of an unread request body.
47+// With no server Read/WriteTimeout those would hang forever, so finish arms
48+// one last bounded read and write deadline (idle from now) before the handler
49+// returns and leaves it in place. net/http clears the write deadline itself
50+// after the response is finished and replaces the read deadline with the
51+// keep-alive idle / header deadline before the next request, so nothing leaks
52+// into the next request. The timer never touches the controller after return.
53+//
54+// Limits: a handler that ignores the canceled context and never touches the
55+// connection again keeps its HTTP/1 connection until it returns. A handler that
56+// enables HTTP/1 full-duplex mode (ResponseController.EnableFullDuplex) is not
57+// covered for the unread-body drain: net/http clears the read deadline before
58+// that drain, so such a handler must consume or bound its request body itself.
59+// A non-positive idle disables the middleware.
60+func TransferIdleTimeout(idle time.Duration) func(http.Handler) http.Handler {
61+ return func(next http.Handler) http.Handler {
62+ if idle <= 0 {
63+ return next
64+ }
65+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+ ctx, cancel := context.WithCancel(r.Context())
67+ hasBody := r.Body != nil && r.Body != http.NoBody
68+ state := newIdleState(idle, http.NewResponseController(w), cancel, hasBody)
69+ // finish marks the state done under the mutex before this
70+ // function returns, so nothing touches the controller afterwards.
71+ defer state.finish()
72+
73+ req := r.WithContext(ctx)
74+ if hasBody {
75+ req.Body = &idleBody{ReadCloser: r.Body, state: state}
76+ }
77+ next.ServeHTTP(&idleResponseWriter{ResponseWriter: w, state: state}, req)
78+ })
79+ }
80+}
81+
82+// idleState is the request-lifetime state shared by the writer, the body and
83+// the timer. The controller is only used while the state is live, under mu.
84+type idleState struct {
85+ idle time.Duration
86+ rc *http.ResponseController
87+ cancel context.CancelFunc
88+
89+ hasBody bool // request has a body that net/http may still have to drain
90+
91+ mu sync.Mutex
92+ timer *time.Timer
93+ last time.Time
94+ done bool // handler returned or connection hijacked
95+ expired bool // the timer fired and expired the connection deadlines
96+ hijacked bool
97+}
98+
99+func newIdleState(idle time.Duration, rc *http.ResponseController, cancel context.CancelFunc, hasBody bool) *idleState {
100+ s := &idleState{idle: idle, rc: rc, cancel: cancel, last: time.Now(), hasBody: hasBody}
101+ s.mu.Lock()
102+ s.timer = time.AfterFunc(idle, s.onTimer)
103+ s.mu.Unlock()
104+ return s
105+}
106+
107+// progress records activity. It never touches the controller.
108+func (s *idleState) progress() {
109+ s.mu.Lock()
110+ if !s.done {
111+ s.last = time.Now()
112+ }
113+ s.mu.Unlock()
114+}
115+
116+// live reports whether the request is still being served.
117+func (s *idleState) live() bool {
118+ s.mu.Lock()
119+ defer s.mu.Unlock()
120+ return !s.done
121+}
122+
123+// onTimer runs on timer expiry. Progress only stamps s.last, so the timer is
124+// re-armed for the remainder instead of being reset on every event; this also
125+// makes progress racing with expiry safe.
126+func (s *idleState) onTimer() {
127+ s.mu.Lock()
128+ defer s.mu.Unlock()
129+ if s.done {
130+ return
131+ }
132+ if remaining := s.idle - time.Since(s.last); remaining > 0 {
133+ s.timer.Reset(remaining)
134+ return
135+ }
136+ s.expired = true
137+ s.cancel()
138+ // Held under mu so finish() cannot complete while these run.
139+ now := time.Now()
140+ _ = s.rc.SetWriteDeadline(now)
141+ _ = s.rc.SetReadDeadline(now)
142+}
143+
144+// finish ends the request lifetime. After it returns, no code path calls the
145+// controller. It is safe to call more than once. Unless the connection was
146+// hijacked or already expired, it first arms bounded deadlines so net/http's
147+// post-handler work (final flush, unread body drain) cannot block forever.
148+func (s *idleState) finish() {
149+ s.mu.Lock()
150+ wasDone := s.done
151+ s.done = true
152+ s.timer.Stop()
153+ if !wasDone && !s.expired && !s.hijacked {
154+ deadline := time.Now().Add(s.idle)
155+ _ = s.rc.SetWriteDeadline(deadline)
156+ if s.hasBody {
157+ _ = s.rc.SetReadDeadline(deadline)
158+ }
159+ }
160+ s.mu.Unlock()
161+ s.cancel()
162+}
163+
164+// flush flushes through the controller unless the request finished. The mutex
165+// is not held during the flush: it may block on a slow client, and the timer
166+// callback needs the mutex to expire the deadlines that unblock it. net/http
167+// forbids using the writer after the handler returned or concurrently with its
168+// return, so the live check is a guard against misuse, not a synchronisation
169+// point for the flush itself.
170+func (s *idleState) flush() error {
171+ if !s.live() {
172+ return errTransferFinished
173+ }
174+ return s.rc.Flush()
175+}
176+
177+// hijack hijacks the connection without holding the mutex: HTTP/1 Hijack may
178+// flush pending headers and block on a client that does not read, and the timer
179+// needs the mutex to expire the deadlines that unblock it. Enforcement stays
180+// active until the hijack succeeds; only then is the timer stopped. If the
181+// timer fired meanwhile, the deadlines it set on the connection are cleared so
182+// the upgraded stream is not cut.
183+func (s *idleState) hijack() (net.Conn, *bufio.ReadWriter, error) {
184+ if !s.live() {
185+ return nil, nil, errTransferFinished
186+ }
187+ conn, rw, err := s.rc.Hijack()
188+ if err != nil {
189+ return conn, rw, err
190+ }
191+ s.mu.Lock()
192+ s.done = true
193+ s.hijacked = true
194+ s.timer.Stop()
195+ expired := s.expired
196+ s.mu.Unlock()
197+ if expired {
198+ _ = conn.SetDeadline(time.Time{})
199+ }
200+ return conn, rw, nil
201+}
202+
203+// idleResponseWriter reports response progress.
204+type idleResponseWriter struct {
205+ http.ResponseWriter
206+ state *idleState
207+}
208+
209+// Write hands p to the connection in idleWriteChunk pieces and records progress
210+// after each piece.
211+func (w *idleResponseWriter) Write(p []byte) (int, error) {
212+ if !w.state.live() {
213+ return 0, errTransferFinished
214+ }
215+ if len(p) == 0 {
216+ return w.ResponseWriter.Write(p)
217+ }
218+ written := 0
219+ for written < len(p) {
220+ chunk := p[written:min(written+idleWriteChunk, len(p))]
221+ n, err := w.ResponseWriter.Write(chunk)
222+ written += n
223+ if n > 0 {
224+ w.state.progress()
225+ }
226+ if err != nil {
227+ return written, err
228+ }
229+ }
230+ return written, nil
231+}
232+
233+// FlushError flushes and records progress. It lets http.ResponseController
234+// report flush failures.
235+func (w *idleResponseWriter) FlushError() error {
236+ err := w.state.flush()
237+ if err == nil {
238+ w.state.progress()
239+ }
240+ return err
241+}
242+
243+// Flush implements http.Flusher.
244+func (w *idleResponseWriter) Flush() {
245+ _ = w.FlushError()
246+}
247+
248+// Hijack implements http.Hijacker and stops the inactivity timer: upgraded
249+// streams (e.g. WebSocket) are exempt.
250+func (w *idleResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
251+ return w.state.hijack()
252+}
253+
254+// Unwrap exposes the underlying writer to http.ResponseController.
255+func (w *idleResponseWriter) Unwrap() http.ResponseWriter {
256+ return w.ResponseWriter
257+}
258+
259+// idleBody reports request body progress. It only records timestamps, so it is
260+// safe to Read or Close after the handler returned (the HTTP/2 transport closes
261+// the outbound body asynchronously).
262+type idleBody struct {
263+ io.ReadCloser
264+ state *idleState
265+
266+ closeOnce sync.Once
267+ closeErr error
268+}
269+
270+func (b *idleBody) Read(p []byte) (int, error) {
271+ n, err := b.ReadCloser.Read(p)
272+ if n > 0 {
273+ b.state.progress()
274+ }
275+ return n, err
276+}
277+
278+// Close is idempotent.
279+func (b *idleBody) Close() error {
280+ b.closeOnce.Do(func() { b.closeErr = b.ReadCloser.Close() })
281+ return b.closeErr
282+}
A internal/adapters/in/http/middleware/transfer_idle_test.go +638 -0
@@ -0,0 +1,638 @@
1+package middleware
2+
3+import (
4+ "bufio"
5+ "context"
6+ "crypto/tls"
7+ "io"
8+ "net"
9+ "net/http"
10+ "net/http/httptest"
11+ "net/http/httputil"
12+ "net/url"
13+ "strings"
14+ "sync"
15+ "sync/atomic"
16+ "testing"
17+ "time"
18+
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+const testIdle = 300 * time.Millisecond
24+
25+func newIdleServer(t *testing.T, handler http.Handler) *httptest.Server {
26+ t.Helper()
27+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(handler))
28+ srv.Config.ReadHeaderTimeout = 5 * time.Second
29+ srv.Start()
30+ t.Cleanup(srv.Close)
31+ return srv
32+}
33+
34+func TestTransferIdleTimeout_SlowActiveDownloadCompletes(t *testing.T) {
35+ const chunks = 12 // 12 x 50ms = 600ms, twice the idle timeout
36+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
37+ for range chunks {
38+ _, _ = w.Write([]byte("x"))
39+ w.(http.Flusher).Flush()
40+ time.Sleep(50 * time.Millisecond)
41+ }
42+ }))
43+
44+ start := time.Now()
45+ resp, err := http.Get(srv.URL)
46+ require.NoError(t, err)
47+ defer resp.Body.Close()
48+ body, err := io.ReadAll(resp.Body)
49+ require.NoError(t, err)
50+ assert.Len(t, body, chunks)
51+ assert.Greater(t, time.Since(start), testIdle, "transfer must outlast the idle timeout")
52+}
53+
54+func TestTransferIdleTimeout_SilentResponseIsCut(t *testing.T) {
55+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
56+ _, _ = w.Write([]byte("start"))
57+ w.(http.Flusher).Flush()
58+ time.Sleep(3 * testIdle)
59+ _, _ = w.Write([]byte("late"))
60+ w.(http.Flusher).Flush()
61+ }))
62+
63+ resp, err := http.Get(srv.URL)
64+ require.NoError(t, err)
65+ defer resp.Body.Close()
66+ body, err := io.ReadAll(resp.Body)
67+ require.Error(t, err, "connection must be cut after the idle timeout")
68+ assert.Equal(t, "start", string(body))
69+}
70+
71+func TestTransferIdleTimeout_SilentHandlerBeforeFirstByteIsCut(t *testing.T) {
72+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
73+ time.Sleep(3 * testIdle)
74+ _, _ = w.Write([]byte("late"))
75+ }))
76+
77+ resp, err := http.Get(srv.URL)
78+ if err == nil {
79+ defer resp.Body.Close()
80+ _, err = io.ReadAll(resp.Body)
81+ }
82+ require.Error(t, err)
83+}
84+
85+func TestTransferIdleTimeout_SlowActiveUploadCompletes(t *testing.T) {
86+ const chunks = 10 // 10 x 60ms = 600ms
87+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88+ n, err := io.Copy(io.Discard, r.Body)
89+ if err != nil {
90+ http.Error(w, err.Error(), http.StatusBadRequest)
91+ return
92+ }
93+ _, _ = w.Write([]byte(strings.Repeat("o", int(n))))
94+ }))
95+
96+ pr, pw := io.Pipe()
97+ go func() {
98+ for range chunks {
99+ _, _ = pw.Write([]byte("u"))
100+ time.Sleep(60 * time.Millisecond)
101+ }
102+ _ = pw.Close()
103+ }()
104+ resp, err := http.Post(srv.URL, "application/octet-stream", pr)
105+ require.NoError(t, err)
106+ defer resp.Body.Close()
107+ body, err := io.ReadAll(resp.Body)
108+ require.NoError(t, err)
109+ assert.Equal(t, http.StatusOK, resp.StatusCode)
110+ assert.Len(t, body, chunks)
111+}
112+
113+func TestTransferIdleTimeout_StalledUploadIsCut(t *testing.T) {
114+ var readErr atomic.Value
115+ done := make(chan struct{})
116+ srv := newIdleServer(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
117+ defer close(done)
118+ _, err := io.Copy(io.Discard, r.Body)
119+ if err != nil {
120+ readErr.Store(err)
121+ }
122+ }))
123+
124+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
125+ require.NoError(t, err)
126+ defer conn.Close()
127+ _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nabc"))
128+ require.NoError(t, err)
129+
130+ select {
131+ case <-done:
132+ case <-time.After(5 * testIdle):
133+ t.Fatal("stalled upload was not cut")
134+ }
135+ assert.NotNil(t, readErr.Load(), "body read must fail after the idle timeout")
136+}
137+
138+func TestTransferIdleTimeout_PreservesFlusherAndUnwrap(t *testing.T) {
139+ var isFlusher, unwrapped bool
140+ h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
141+ _, isFlusher = w.(http.Flusher)
142+ _, unwrapped = w.(interface{ Unwrap() http.ResponseWriter })
143+ }))
144+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
145+ assert.True(t, isFlusher)
146+ assert.True(t, unwrapped)
147+}
148+
149+func TestTransferIdleTimeout_HijackStillWorks(t *testing.T) {
150+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
151+ hj, ok := w.(http.Hijacker)
152+ if !ok {
153+ http.Error(w, "no hijacker", http.StatusInternalServerError)
154+ return
155+ }
156+ conn, rw, err := hj.Hijack()
157+ if err != nil {
158+ return
159+ }
160+ defer conn.Close()
161+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n")
162+ _ = rw.Flush()
163+ // Stay open longer than the idle timeout: hijacked streams are exempt.
164+ time.Sleep(2 * testIdle)
165+ _, _ = rw.WriteString("hello")
166+ _ = rw.Flush()
167+ }))
168+
169+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
170+ require.NoError(t, err)
171+ defer conn.Close()
172+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n"))
173+ require.NoError(t, err)
174+
175+ br := bufio.NewReader(conn)
176+ resp, err := http.ReadResponse(br, nil)
177+ require.NoError(t, err)
178+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
179+ buf := make([]byte, 5)
180+ _, err = io.ReadFull(br, buf)
181+ require.NoError(t, err)
182+ assert.Equal(t, "hello", string(buf))
183+}
184+
185+func TestTransferIdleTimeout_ReverseProxyFlushesStream(t *testing.T) {
186+ // Flush propagation through a wrapped writer, as used by httputil.ReverseProxy.
187+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
188+ rc := http.NewResponseController(w)
189+ for range 8 {
190+ _, _ = w.Write([]byte("."))
191+ if err := rc.Flush(); err != nil {
192+ return
193+ }
194+ time.Sleep(50 * time.Millisecond)
195+ }
196+ }))
197+ resp, err := http.Get(srv.URL)
198+ require.NoError(t, err)
199+ defer resp.Body.Close()
200+ body, err := io.ReadAll(resp.Body)
201+ require.NoError(t, err)
202+ assert.Len(t, body, 8)
203+}
204+
205+func TestTransferIdleTimeout_DisabledWhenNonPositive(t *testing.T) {
206+ called := false
207+ inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
208+ h := TransferIdleTimeout(0)(inner)
209+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
210+ assert.True(t, called)
211+}
212+
213+func TestTransferIdleTimeout_HTTP2(t *testing.T) {
214+ const chunks = 12
215+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
216+ for range chunks {
217+ _, _ = w.Write([]byte("x"))
218+ w.(http.Flusher).Flush()
219+ time.Sleep(50 * time.Millisecond)
220+ }
221+ })))
222+ srv.EnableHTTP2 = true
223+ srv.StartTLS()
224+ defer srv.Close()
225+
226+ client := srv.Client()
227+ resp, err := client.Get(srv.URL)
228+ require.NoError(t, err)
229+ defer resp.Body.Close()
230+ assert.Equal(t, 2, resp.ProtoMajor)
231+ body, err := io.ReadAll(resp.Body)
232+ require.NoError(t, err)
233+ assert.Len(t, body, chunks)
234+}
235+
236+// pipeListener serves connections created with dial over net.Pipe, which has no
237+// kernel buffering: a write completes only when the peer reads it.
238+type pipeListener struct {
239+ conns chan net.Conn
240+ done chan struct{}
241+}
242+
243+func newPipeListener() *pipeListener {
244+ return &pipeListener{conns: make(chan net.Conn), done: make(chan struct{})}
245+}
246+
247+func (l *pipeListener) Accept() (net.Conn, error) {
248+ select {
249+ case c := <-l.conns:
250+ return c, nil
251+ case <-l.done:
252+ return nil, net.ErrClosed
253+ }
254+}
255+
256+func (l *pipeListener) Close() error {
257+ select {
258+ case <-l.done:
259+ default:
260+ close(l.done)
261+ }
262+ return nil
263+}
264+
265+func (l *pipeListener) Addr() net.Addr { return pipeAddr{} }
266+
267+func (l *pipeListener) dial() net.Conn {
268+ client, server := net.Pipe()
269+ l.conns <- server
270+ return client
271+}
272+
273+type pipeAddr struct{}
274+
275+func (pipeAddr) Network() string { return "pipe" }
276+func (pipeAddr) String() string { return "pipe" }
277+
278+// slowReadConn reads at most 4 KiB every pause, like a congested client.
279+type slowReadConn struct {
280+ net.Conn
281+ pause time.Duration
282+}
283+
284+func (c *slowReadConn) Read(p []byte) (int, error) {
285+ time.Sleep(c.pause)
286+ return c.Conn.Read(p[:min(len(p), 4096)])
287+}
288+
289+func servePipe(t *testing.T, handler http.Handler) *pipeListener {
290+ t.Helper()
291+ ln := newPipeListener()
292+ srv := &http.Server{Handler: TransferIdleTimeout(testIdle)(handler), ReadHeaderTimeout: 5 * time.Second}
293+ go func() { _ = srv.Serve(ln) }()
294+ t.Cleanup(func() { _ = srv.Close() })
295+ return ln
296+}
297+
298+func TestTransferIdleTimeout_BackpressuredButReadingClientIsNotCut(t *testing.T) {
299+ const size = 64 * 1024
300+ ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
301+ _, _ = w.Write([]byte(strings.Repeat("d", size)))
302+ }))
303+ client := &http.Client{Transport: &http.Transport{
304+ DisableKeepAlives: true,
305+ DialContext: func(context.Context, string, string) (net.Conn, error) {
306+ return &slowReadConn{Conn: ln.dial(), pause: 25 * time.Millisecond}, nil
307+ },
308+ }}
309+
310+ start := time.Now()
311+ resp, err := client.Get("http://pipe/")
312+ require.NoError(t, err)
313+ defer resp.Body.Close()
314+ body, err := io.ReadAll(resp.Body)
315+ require.NoError(t, err, "a client that keeps reading must not be cut")
316+ assert.Len(t, body, size)
317+ assert.Greater(t, time.Since(start), testIdle, "the transfer must outlast the idle timeout")
318+}
319+
320+func TestTransferIdleTimeout_NonReadingClientUnblocksWrite(t *testing.T) {
321+ writeErr := make(chan error, 1)
322+ ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
323+ _, err := w.Write([]byte(strings.Repeat("d", 1<<20)))
324+ writeErr <- err
325+ }))
326+ conn := ln.dial()
327+ defer conn.Close()
328+ _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"))
329+ require.NoError(t, err)
330+
331+ select {
332+ case err := <-writeErr:
333+ require.Error(t, err, "write to a client that never reads must fail")
334+ case <-time.After(10 * testIdle):
335+ t.Fatal("blocked write was not unblocked by the inactivity timeout")
336+ }
337+}
338+
339+func TestTransferIdleTimeout_SilentHandlerContextCanceledAndConnectionClosed(t *testing.T) {
340+ ctxDone := make(chan struct{})
341+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
342+ _, _ = w.Write([]byte("start"))
343+ w.(http.Flusher).Flush()
344+ <-r.Context().Done() // a ReverseProxy would abort its upstream request here
345+ close(ctxDone)
346+ }))
347+
348+ client := &http.Client{Timeout: 10 * time.Second}
349+ begin := time.Now()
350+ resp, err := client.Get(srv.URL)
351+ require.NoError(t, err)
352+ defer resp.Body.Close()
353+ body, err := io.ReadAll(resp.Body)
354+ require.Error(t, err, "the client connection must be closed, not completed cleanly")
355+ assert.Equal(t, "start", string(body))
356+ assert.Less(t, time.Since(begin), 5*time.Second)
357+
358+ select {
359+ case <-ctxDone:
360+ case <-time.After(5 * time.Second):
361+ t.Fatal("request context was not canceled")
362+ }
363+}
364+
365+func TestTransferIdleTimeout_ProgressKeepsContextAlive(t *testing.T) {
366+ var canceled atomic.Bool
367+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
368+ for range 12 {
369+ _, _ = w.Write([]byte("x"))
370+ w.(http.Flusher).Flush()
371+ select {
372+ case <-r.Context().Done():
373+ canceled.Store(true)
374+ return
375+ case <-time.After(50 * time.Millisecond):
376+ }
377+ }
378+ }))
379+ resp, err := http.Get(srv.URL)
380+ require.NoError(t, err)
381+ defer resp.Body.Close()
382+ body, err := io.ReadAll(resp.Body)
383+ require.NoError(t, err)
384+ assert.Len(t, body, 12)
385+ assert.False(t, canceled.Load())
386+}
387+
388+func TestTransferIdleTimeout_BodyUseAfterHandlerReturnedIsSafe(t *testing.T) {
389+ var body io.ReadCloser
390+ h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
391+ body = r.Body
392+ }))
393+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("payload"))
394+ h.ServeHTTP(httptest.NewRecorder(), req)
395+
396+ // Like http.Transport closing the outbound body after the handler returned.
397+ require.NoError(t, body.Close())
398+ require.NoError(t, body.Close(), "Close must be idempotent")
399+ _, _ = body.Read(make([]byte, 4))
400+}
401+
402+// TestTransferIdleTimeout_HTTP2ReverseProxyEarlyResponseDuringUpload covers the
403+// transport closing the outbound request body after the handler returned, when
404+// the upstream answers early (413) while the client is still uploading. Run
405+// with -race.
406+func TestTransferIdleTimeout_HTTP2ReverseProxyEarlyResponseDuringUpload(t *testing.T) {
407+ upstream := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
408+ http.Error(w, "too large", http.StatusRequestEntityTooLarge)
409+ }))
410+ upstream.EnableHTTP2 = true
411+ upstream.StartTLS()
412+ defer upstream.Close()
413+
414+ target, err := url.Parse(upstream.URL)
415+ require.NoError(t, err)
416+ proxy := httputil.NewSingleHostReverseProxy(target)
417+ proxy.Transport = &http.Transport{
418+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test upstream
419+ ForceAttemptHTTP2: true,
420+ }
421+ front := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(proxy))
422+ front.EnableHTTP2 = true
423+ front.StartTLS()
424+ defer front.Close()
425+
426+ var wg sync.WaitGroup
427+ for range 8 {
428+ wg.Add(1)
429+ go func() {
430+ defer wg.Done()
431+ for range 10 {
432+ uploadUntilEarlyResponse(t, front)
433+ }
434+ }()
435+ }
436+ wg.Wait()
437+}
438+
439+func uploadUntilEarlyResponse(t *testing.T, front *httptest.Server) {
440+ t.Helper()
441+ pr, pw := io.Pipe()
442+ writerDone := make(chan struct{})
443+ go func() {
444+ defer close(writerDone)
445+ chunk := []byte(strings.Repeat("u", 4096))
446+ for range 200 {
447+ if _, err := pw.Write(chunk); err != nil {
448+ return
449+ }
450+ time.Sleep(time.Millisecond)
451+ }
452+ _ = pw.Close()
453+ }()
454+
455+ defer func() {
456+ _ = pr.CloseWithError(io.ErrClosedPipe)
457+ <-writerDone
458+ }()
459+
460+ req, err := http.NewRequest(http.MethodPost, front.URL, pr)
461+ if !assert.NoError(t, err) {
462+ return
463+ }
464+ resp, err := front.Client().Do(req)
465+ if !assert.NoError(t, err, "the early 413 must reach the client") {
466+ return
467+ }
468+ defer resp.Body.Close()
469+ assert.Equal(t, 2, resp.ProtoMajor)
470+ assert.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
471+ _, _ = io.Copy(io.Discard, resp.Body)
472+}
473+
474+// TestTransferIdleTimeout_HTTP2BodyUsedAfterHandlerReturned uses the request
475+// body after net/http's HTTP/2 server finished the handler (it drops its
476+// response writer state then), as http.Transport does when it closes an
477+// outbound body asynchronously. It must neither panic nor race.
478+func TestTransferIdleTimeout_HTTP2BodyUsedAfterHandlerReturned(t *testing.T) {
479+ bodyCh := make(chan io.ReadCloser, 1)
480+ returned := make(chan struct{})
481+ inner := TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
482+ bodyCh <- r.Body
483+ _, _ = w.Write([]byte("early"))
484+ }))
485+ srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
486+ inner.ServeHTTP(w, r)
487+ close(returned)
488+ }))
489+ srv.EnableHTTP2 = true
490+ srv.StartTLS()
491+ defer srv.Close()
492+
493+ pr, pw := io.Pipe()
494+ defer pw.Close()
495+ req, err := http.NewRequest(http.MethodPost, srv.URL, pr)
496+ require.NoError(t, err)
497+ go func() { _, _ = pw.Write([]byte("partial")) }()
498+ resp, err := srv.Client().Do(req)
499+ require.NoError(t, err)
500+ _, _ = io.Copy(io.Discard, resp.Body)
501+ _ = resp.Body.Close()
502+
503+ body := <-bodyCh
504+ <-returned
505+ time.Sleep(100 * time.Millisecond) // let the server loop process handlerDone
506+
507+ _, _ = body.Read(make([]byte, 8))
508+ require.NoError(t, body.Close())
509+ require.NoError(t, body.Close())
510+}
511+
512+// trackedPipeServer serves net.Pipe connections and reports when the server
513+// side closes one, so tests can assert a stalled connection is released.
514+func trackedPipeServer(t *testing.T, handler http.Handler) (*pipeListener, <-chan struct{}) {
515+ t.Helper()
516+ closed := make(chan struct{})
517+ var once sync.Once
518+ ln := newPipeListener()
519+ srv := &http.Server{
520+ Handler: TransferIdleTimeout(testIdle)(handler),
521+ ReadHeaderTimeout: 5 * time.Second,
522+ ConnState: func(_ net.Conn, state http.ConnState) {
523+ if state == http.StateClosed {
524+ once.Do(func() { close(closed) })
525+ }
526+ },
527+ }
528+ go func() { _ = srv.Serve(ln) }()
529+ t.Cleanup(func() { _ = srv.Close() })
530+ return ln, closed
531+}
532+
533+func requireClosed(t *testing.T, closed <-chan struct{}, what string) {
534+ t.Helper()
535+ select {
536+ case <-closed:
537+ case <-time.After(20 * testIdle):
538+ t.Fatalf("server connection still held: %s", what)
539+ }
540+}
541+
542+// The handler returns with a small unflushed response; net/http's final flush
543+// then blocks on a client that never reads (net.Pipe has no buffering).
544+func TestTransferIdleTimeout_FinalFlushToNonReadingClientIsBounded(t *testing.T) {
545+ ln, closed := trackedPipeServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
546+ _, _ = w.Write([]byte("small"))
547+ }))
548+ conn := ln.dial()
549+ defer conn.Close()
550+ _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"))
551+ require.NoError(t, err)
552+
553+ requireClosed(t, closed, "final response flush to a non-reading client")
554+}
555+
556+// The client declares a body it never sends and the handler answers without
557+// reading it; net/http then tries to drain the unread body.
558+func TestTransferIdleTimeout_UnreadDeclaredBodyDrainIsBounded(t *testing.T) {
559+ ln, closed := trackedPipeServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
560+ http.Error(w, "unauthorized", http.StatusUnauthorized)
561+ }))
562+ conn := ln.dial()
563+ defer conn.Close()
564+ go func() { _, _ = io.Copy(io.Discard, conn) }() // read the response, then stay silent
565+ _, err := conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n"))
566+ require.NoError(t, err)
567+
568+ requireClosed(t, closed, "drain of an unread declared request body")
569+}
570+
571+func TestTransferIdleTimeout_UnreadDeclaredBodyDrainIsBoundedOverTCP(t *testing.T) {
572+ closed := make(chan struct{})
573+ var once sync.Once
574+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
575+ http.Error(w, "unauthorized", http.StatusUnauthorized)
576+ })))
577+ srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
578+ if state == http.StateClosed {
579+ once.Do(func() { close(closed) })
580+ }
581+ }
582+ srv.Start()
583+ defer srv.Close()
584+
585+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
586+ require.NoError(t, err)
587+ defer conn.Close()
588+ _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n"))
589+ require.NoError(t, err)
590+
591+ requireClosed(t, closed, "drain of an unread declared request body over TCP")
592+}
593+
594+// Leftover deadlines armed at handler return must not leak into the next
595+// keep-alive request on the same connection.
596+func TestTransferIdleTimeout_KeepAliveNextRequestNotAffectedByLeftoverDeadlines(t *testing.T) {
597+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
598+ _, _ = io.Copy(io.Discard, r.Body)
599+ _, _ = w.Write([]byte("ok"))
600+ })))
601+ srv.Config.IdleTimeout = 10 * time.Second
602+ srv.Start()
603+ defer srv.Close()
604+
605+ client := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 1}}
606+ for i := range 3 {
607+ resp, err := client.Post(srv.URL, "text/plain", strings.NewReader("body"))
608+ require.NoError(t, err, "request %d", i)
609+ _, err = io.ReadAll(resp.Body)
610+ require.NoError(t, err)
611+ _ = resp.Body.Close()
612+ time.Sleep(2 * testIdle) // outlive the leftover deadline while the connection idles
613+ }
614+}
615+
616+// Hijack flushes pending headers; it must not hold the timer's mutex while it
617+// blocks on a client that does not read.
618+func TestTransferIdleTimeout_HijackBlockedOnNonReadingClientIsBounded(t *testing.T) {
619+ hijackErr := make(chan error, 1)
620+ ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
621+ w.WriteHeader(http.StatusOK)
622+ conn, _, err := w.(http.Hijacker).Hijack()
623+ if conn != nil {
624+ _ = conn.Close()
625+ }
626+ hijackErr <- err
627+ }))
628+ conn := ln.dial()
629+ defer conn.Close()
630+ _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"))
631+ require.NoError(t, err)
632+
633+ select {
634+ case <-hijackErr:
635+ case <-time.After(20 * testIdle):
636+ t.Fatal("Hijack blocked on a non-reading client was not bounded")
637+ }
638+}
@@ -0,0 +1,638 @@
1+package middleware
2+
3+import (
4+ "bufio"
5+ "context"
6+ "crypto/tls"
7+ "io"
8+ "net"
9+ "net/http"
10+ "net/http/httptest"
11+ "net/http/httputil"
12+ "net/url"
13+ "strings"
14+ "sync"
15+ "sync/atomic"
16+ "testing"
17+ "time"
18+
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+const testIdle = 300 * time.Millisecond
24+
25+func newIdleServer(t *testing.T, handler http.Handler) *httptest.Server {
26+ t.Helper()
27+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(handler))
28+ srv.Config.ReadHeaderTimeout = 5 * time.Second
29+ srv.Start()
30+ t.Cleanup(srv.Close)
31+ return srv
32+}
33+
34+func TestTransferIdleTimeout_SlowActiveDownloadCompletes(t *testing.T) {
35+ const chunks = 12 // 12 x 50ms = 600ms, twice the idle timeout
36+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
37+ for range chunks {
38+ _, _ = w.Write([]byte("x"))
39+ w.(http.Flusher).Flush()
40+ time.Sleep(50 * time.Millisecond)
41+ }
42+ }))
43+
44+ start := time.Now()
45+ resp, err := http.Get(srv.URL)
46+ require.NoError(t, err)
47+ defer resp.Body.Close()
48+ body, err := io.ReadAll(resp.Body)
49+ require.NoError(t, err)
50+ assert.Len(t, body, chunks)
51+ assert.Greater(t, time.Since(start), testIdle, "transfer must outlast the idle timeout")
52+}
53+
54+func TestTransferIdleTimeout_SilentResponseIsCut(t *testing.T) {
55+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
56+ _, _ = w.Write([]byte("start"))
57+ w.(http.Flusher).Flush()
58+ time.Sleep(3 * testIdle)
59+ _, _ = w.Write([]byte("late"))
60+ w.(http.Flusher).Flush()
61+ }))
62+
63+ resp, err := http.Get(srv.URL)
64+ require.NoError(t, err)
65+ defer resp.Body.Close()
66+ body, err := io.ReadAll(resp.Body)
67+ require.Error(t, err, "connection must be cut after the idle timeout")
68+ assert.Equal(t, "start", string(body))
69+}
70+
71+func TestTransferIdleTimeout_SilentHandlerBeforeFirstByteIsCut(t *testing.T) {
72+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
73+ time.Sleep(3 * testIdle)
74+ _, _ = w.Write([]byte("late"))
75+ }))
76+
77+ resp, err := http.Get(srv.URL)
78+ if err == nil {
79+ defer resp.Body.Close()
80+ _, err = io.ReadAll(resp.Body)
81+ }
82+ require.Error(t, err)
83+}
84+
85+func TestTransferIdleTimeout_SlowActiveUploadCompletes(t *testing.T) {
86+ const chunks = 10 // 10 x 60ms = 600ms
87+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88+ n, err := io.Copy(io.Discard, r.Body)
89+ if err != nil {
90+ http.Error(w, err.Error(), http.StatusBadRequest)
91+ return
92+ }
93+ _, _ = w.Write([]byte(strings.Repeat("o", int(n))))
94+ }))
95+
96+ pr, pw := io.Pipe()
97+ go func() {
98+ for range chunks {
99+ _, _ = pw.Write([]byte("u"))
100+ time.Sleep(60 * time.Millisecond)
101+ }
102+ _ = pw.Close()
103+ }()
104+ resp, err := http.Post(srv.URL, "application/octet-stream", pr)
105+ require.NoError(t, err)
106+ defer resp.Body.Close()
107+ body, err := io.ReadAll(resp.Body)
108+ require.NoError(t, err)
109+ assert.Equal(t, http.StatusOK, resp.StatusCode)
110+ assert.Len(t, body, chunks)
111+}
112+
113+func TestTransferIdleTimeout_StalledUploadIsCut(t *testing.T) {
114+ var readErr atomic.Value
115+ done := make(chan struct{})
116+ srv := newIdleServer(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
117+ defer close(done)
118+ _, err := io.Copy(io.Discard, r.Body)
119+ if err != nil {
120+ readErr.Store(err)
121+ }
122+ }))
123+
124+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
125+ require.NoError(t, err)
126+ defer conn.Close()
127+ _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nabc"))
128+ require.NoError(t, err)
129+
130+ select {
131+ case <-done:
132+ case <-time.After(5 * testIdle):
133+ t.Fatal("stalled upload was not cut")
134+ }
135+ assert.NotNil(t, readErr.Load(), "body read must fail after the idle timeout")
136+}
137+
138+func TestTransferIdleTimeout_PreservesFlusherAndUnwrap(t *testing.T) {
139+ var isFlusher, unwrapped bool
140+ h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
141+ _, isFlusher = w.(http.Flusher)
142+ _, unwrapped = w.(interface{ Unwrap() http.ResponseWriter })
143+ }))
144+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
145+ assert.True(t, isFlusher)
146+ assert.True(t, unwrapped)
147+}
148+
149+func TestTransferIdleTimeout_HijackStillWorks(t *testing.T) {
150+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
151+ hj, ok := w.(http.Hijacker)
152+ if !ok {
153+ http.Error(w, "no hijacker", http.StatusInternalServerError)
154+ return
155+ }
156+ conn, rw, err := hj.Hijack()
157+ if err != nil {
158+ return
159+ }
160+ defer conn.Close()
161+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n")
162+ _ = rw.Flush()
163+ // Stay open longer than the idle timeout: hijacked streams are exempt.
164+ time.Sleep(2 * testIdle)
165+ _, _ = rw.WriteString("hello")
166+ _ = rw.Flush()
167+ }))
168+
169+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
170+ require.NoError(t, err)
171+ defer conn.Close()
172+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n"))
173+ require.NoError(t, err)
174+
175+ br := bufio.NewReader(conn)
176+ resp, err := http.ReadResponse(br, nil)
177+ require.NoError(t, err)
178+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
179+ buf := make([]byte, 5)
180+ _, err = io.ReadFull(br, buf)
181+ require.NoError(t, err)
182+ assert.Equal(t, "hello", string(buf))
183+}
184+
185+func TestTransferIdleTimeout_ReverseProxyFlushesStream(t *testing.T) {
186+ // Flush propagation through a wrapped writer, as used by httputil.ReverseProxy.
187+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
188+ rc := http.NewResponseController(w)
189+ for range 8 {
190+ _, _ = w.Write([]byte("."))
191+ if err := rc.Flush(); err != nil {
192+ return
193+ }
194+ time.Sleep(50 * time.Millisecond)
195+ }
196+ }))
197+ resp, err := http.Get(srv.URL)
198+ require.NoError(t, err)
199+ defer resp.Body.Close()
200+ body, err := io.ReadAll(resp.Body)
201+ require.NoError(t, err)
202+ assert.Len(t, body, 8)
203+}
204+
205+func TestTransferIdleTimeout_DisabledWhenNonPositive(t *testing.T) {
206+ called := false
207+ inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
208+ h := TransferIdleTimeout(0)(inner)
209+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
210+ assert.True(t, called)
211+}
212+
213+func TestTransferIdleTimeout_HTTP2(t *testing.T) {
214+ const chunks = 12
215+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
216+ for range chunks {
217+ _, _ = w.Write([]byte("x"))
218+ w.(http.Flusher).Flush()
219+ time.Sleep(50 * time.Millisecond)
220+ }
221+ })))
222+ srv.EnableHTTP2 = true
223+ srv.StartTLS()
224+ defer srv.Close()
225+
226+ client := srv.Client()
227+ resp, err := client.Get(srv.URL)
228+ require.NoError(t, err)
229+ defer resp.Body.Close()
230+ assert.Equal(t, 2, resp.ProtoMajor)
231+ body, err := io.ReadAll(resp.Body)
232+ require.NoError(t, err)
233+ assert.Len(t, body, chunks)
234+}
235+
236+// pipeListener serves connections created with dial over net.Pipe, which has no
237+// kernel buffering: a write completes only when the peer reads it.
238+type pipeListener struct {
239+ conns chan net.Conn
240+ done chan struct{}
241+}
242+
243+func newPipeListener() *pipeListener {
244+ return &pipeListener{conns: make(chan net.Conn), done: make(chan struct{})}
245+}
246+
247+func (l *pipeListener) Accept() (net.Conn, error) {
248+ select {
249+ case c := <-l.conns:
250+ return c, nil
251+ case <-l.done:
252+ return nil, net.ErrClosed
253+ }
254+}
255+
256+func (l *pipeListener) Close() error {
257+ select {
258+ case <-l.done:
259+ default:
260+ close(l.done)
261+ }
262+ return nil
263+}
264+
265+func (l *pipeListener) Addr() net.Addr { return pipeAddr{} }
266+
267+func (l *pipeListener) dial() net.Conn {
268+ client, server := net.Pipe()
269+ l.conns <- server
270+ return client
271+}
272+
273+type pipeAddr struct{}
274+
275+func (pipeAddr) Network() string { return "pipe" }
276+func (pipeAddr) String() string { return "pipe" }
277+
278+// slowReadConn reads at most 4 KiB every pause, like a congested client.
279+type slowReadConn struct {
280+ net.Conn
281+ pause time.Duration
282+}
283+
284+func (c *slowReadConn) Read(p []byte) (int, error) {
285+ time.Sleep(c.pause)
286+ return c.Conn.Read(p[:min(len(p), 4096)])
287+}
288+
289+func servePipe(t *testing.T, handler http.Handler) *pipeListener {
290+ t.Helper()
291+ ln := newPipeListener()
292+ srv := &http.Server{Handler: TransferIdleTimeout(testIdle)(handler), ReadHeaderTimeout: 5 * time.Second}
293+ go func() { _ = srv.Serve(ln) }()
294+ t.Cleanup(func() { _ = srv.Close() })
295+ return ln
296+}
297+
298+func TestTransferIdleTimeout_BackpressuredButReadingClientIsNotCut(t *testing.T) {
299+ const size = 64 * 1024
300+ ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
301+ _, _ = w.Write([]byte(strings.Repeat("d", size)))
302+ }))
303+ client := &http.Client{Transport: &http.Transport{
304+ DisableKeepAlives: true,
305+ DialContext: func(context.Context, string, string) (net.Conn, error) {
306+ return &slowReadConn{Conn: ln.dial(), pause: 25 * time.Millisecond}, nil
307+ },
308+ }}
309+
310+ start := time.Now()
311+ resp, err := client.Get("http://pipe/")
312+ require.NoError(t, err)
313+ defer resp.Body.Close()
314+ body, err := io.ReadAll(resp.Body)
315+ require.NoError(t, err, "a client that keeps reading must not be cut")
316+ assert.Len(t, body, size)
317+ assert.Greater(t, time.Since(start), testIdle, "the transfer must outlast the idle timeout")
318+}
319+
320+func TestTransferIdleTimeout_NonReadingClientUnblocksWrite(t *testing.T) {
321+ writeErr := make(chan error, 1)
322+ ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
323+ _, err := w.Write([]byte(strings.Repeat("d", 1<<20)))
324+ writeErr <- err
325+ }))
326+ conn := ln.dial()
327+ defer conn.Close()
328+ _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"))
329+ require.NoError(t, err)
330+
331+ select {
332+ case err := <-writeErr:
333+ require.Error(t, err, "write to a client that never reads must fail")
334+ case <-time.After(10 * testIdle):
335+ t.Fatal("blocked write was not unblocked by the inactivity timeout")
336+ }
337+}
338+
339+func TestTransferIdleTimeout_SilentHandlerContextCanceledAndConnectionClosed(t *testing.T) {
340+ ctxDone := make(chan struct{})
341+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
342+ _, _ = w.Write([]byte("start"))
343+ w.(http.Flusher).Flush()
344+ <-r.Context().Done() // a ReverseProxy would abort its upstream request here
345+ close(ctxDone)
346+ }))
347+
348+ client := &http.Client{Timeout: 10 * time.Second}
349+ begin := time.Now()
350+ resp, err := client.Get(srv.URL)
351+ require.NoError(t, err)
352+ defer resp.Body.Close()
353+ body, err := io.ReadAll(resp.Body)
354+ require.Error(t, err, "the client connection must be closed, not completed cleanly")
355+ assert.Equal(t, "start", string(body))
356+ assert.Less(t, time.Since(begin), 5*time.Second)
357+
358+ select {
359+ case <-ctxDone:
360+ case <-time.After(5 * time.Second):
361+ t.Fatal("request context was not canceled")
362+ }
363+}
364+
365+func TestTransferIdleTimeout_ProgressKeepsContextAlive(t *testing.T) {
366+ var canceled atomic.Bool
367+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
368+ for range 12 {
369+ _, _ = w.Write([]byte("x"))
370+ w.(http.Flusher).Flush()
371+ select {
372+ case <-r.Context().Done():
373+ canceled.Store(true)
374+ return
375+ case <-time.After(50 * time.Millisecond):
376+ }
377+ }
378+ }))
379+ resp, err := http.Get(srv.URL)
380+ require.NoError(t, err)
381+ defer resp.Body.Close()
382+ body, err := io.ReadAll(resp.Body)
383+ require.NoError(t, err)
384+ assert.Len(t, body, 12)
385+ assert.False(t, canceled.Load())
386+}
387+
388+func TestTransferIdleTimeout_BodyUseAfterHandlerReturnedIsSafe(t *testing.T) {
389+ var body io.ReadCloser
390+ h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
391+ body = r.Body
392+ }))
393+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("payload"))
394+ h.ServeHTTP(httptest.NewRecorder(), req)
395+
396+ // Like http.Transport closing the outbound body after the handler returned.
397+ require.NoError(t, body.Close())
398+ require.NoError(t, body.Close(), "Close must be idempotent")
399+ _, _ = body.Read(make([]byte, 4))
400+}
401+
402+// TestTransferIdleTimeout_HTTP2ReverseProxyEarlyResponseDuringUpload covers the
403+// transport closing the outbound request body after the handler returned, when
404+// the upstream answers early (413) while the client is still uploading. Run
405+// with -race.
406+func TestTransferIdleTimeout_HTTP2ReverseProxyEarlyResponseDuringUpload(t *testing.T) {
407+ upstream := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
408+ http.Error(w, "too large", http.StatusRequestEntityTooLarge)
409+ }))
410+ upstream.EnableHTTP2 = true
411+ upstream.StartTLS()
412+ defer upstream.Close()
413+
414+ target, err := url.Parse(upstream.URL)
415+ require.NoError(t, err)
416+ proxy := httputil.NewSingleHostReverseProxy(target)
417+ proxy.Transport = &http.Transport{
418+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test upstream
419+ ForceAttemptHTTP2: true,
420+ }
421+ front := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(proxy))
422+ front.EnableHTTP2 = true
423+ front.StartTLS()
424+ defer front.Close()
425+
426+ var wg sync.WaitGroup
427+ for range 8 {
428+ wg.Add(1)
429+ go func() {
430+ defer wg.Done()
431+ for range 10 {
432+ uploadUntilEarlyResponse(t, front)
433+ }
434+ }()
435+ }
436+ wg.Wait()
437+}
438+
439+func uploadUntilEarlyResponse(t *testing.T, front *httptest.Server) {
440+ t.Helper()
441+ pr, pw := io.Pipe()
442+ writerDone := make(chan struct{})
443+ go func() {
444+ defer close(writerDone)
445+ chunk := []byte(strings.Repeat("u", 4096))
446+ for range 200 {
447+ if _, err := pw.Write(chunk); err != nil {
448+ return
449+ }
450+ time.Sleep(time.Millisecond)
451+ }
452+ _ = pw.Close()
453+ }()
454+
455+ defer func() {
456+ _ = pr.CloseWithError(io.ErrClosedPipe)
457+ <-writerDone
458+ }()
459+
460+ req, err := http.NewRequest(http.MethodPost, front.URL, pr)
461+ if !assert.NoError(t, err) {
462+ return
463+ }
464+ resp, err := front.Client().Do(req)
465+ if !assert.NoError(t, err, "the early 413 must reach the client") {
466+ return
467+ }
468+ defer resp.Body.Close()
469+ assert.Equal(t, 2, resp.ProtoMajor)
470+ assert.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
471+ _, _ = io.Copy(io.Discard, resp.Body)
472+}
473+
474+// TestTransferIdleTimeout_HTTP2BodyUsedAfterHandlerReturned uses the request
475+// body after net/http's HTTP/2 server finished the handler (it drops its
476+// response writer state then), as http.Transport does when it closes an
477+// outbound body asynchronously. It must neither panic nor race.
478+func TestTransferIdleTimeout_HTTP2BodyUsedAfterHandlerReturned(t *testing.T) {
479+ bodyCh := make(chan io.ReadCloser, 1)
480+ returned := make(chan struct{})
481+ inner := TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
482+ bodyCh <- r.Body
483+ _, _ = w.Write([]byte("early"))
484+ }))
485+ srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
486+ inner.ServeHTTP(w, r)
487+ close(returned)
488+ }))
489+ srv.EnableHTTP2 = true
490+ srv.StartTLS()
491+ defer srv.Close()
492+
493+ pr, pw := io.Pipe()
494+ defer pw.Close()
495+ req, err := http.NewRequest(http.MethodPost, srv.URL, pr)
496+ require.NoError(t, err)
497+ go func() { _, _ = pw.Write([]byte("partial")) }()
498+ resp, err := srv.Client().Do(req)
499+ require.NoError(t, err)
500+ _, _ = io.Copy(io.Discard, resp.Body)
501+ _ = resp.Body.Close()
502+
503+ body := <-bodyCh
504+ <-returned
505+ time.Sleep(100 * time.Millisecond) // let the server loop process handlerDone
506+
507+ _, _ = body.Read(make([]byte, 8))
508+ require.NoError(t, body.Close())
509+ require.NoError(t, body.Close())
510+}
511+
512+// trackedPipeServer serves net.Pipe connections and reports when the server
513+// side closes one, so tests can assert a stalled connection is released.
514+func trackedPipeServer(t *testing.T, handler http.Handler) (*pipeListener, <-chan struct{}) {
515+ t.Helper()
516+ closed := make(chan struct{})
517+ var once sync.Once
518+ ln := newPipeListener()
519+ srv := &http.Server{
520+ Handler: TransferIdleTimeout(testIdle)(handler),
521+ ReadHeaderTimeout: 5 * time.Second,
522+ ConnState: func(_ net.Conn, state http.ConnState) {
523+ if state == http.StateClosed {
524+ once.Do(func() { close(closed) })
525+ }
526+ },
527+ }
528+ go func() { _ = srv.Serve(ln) }()
529+ t.Cleanup(func() { _ = srv.Close() })
530+ return ln, closed
531+}
532+
533+func requireClosed(t *testing.T, closed <-chan struct{}, what string) {
534+ t.Helper()
535+ select {
536+ case <-closed:
537+ case <-time.After(20 * testIdle):
538+ t.Fatalf("server connection still held: %s", what)
539+ }
540+}
541+
542+// The handler returns with a small unflushed response; net/http's final flush
543+// then blocks on a client that never reads (net.Pipe has no buffering).
544+func TestTransferIdleTimeout_FinalFlushToNonReadingClientIsBounded(t *testing.T) {
545+ ln, closed := trackedPipeServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
546+ _, _ = w.Write([]byte("small"))
547+ }))
548+ conn := ln.dial()
549+ defer conn.Close()
550+ _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"))
551+ require.NoError(t, err)
552+
553+ requireClosed(t, closed, "final response flush to a non-reading client")
554+}
555+
556+// The client declares a body it never sends and the handler answers without
557+// reading it; net/http then tries to drain the unread body.
558+func TestTransferIdleTimeout_UnreadDeclaredBodyDrainIsBounded(t *testing.T) {
559+ ln, closed := trackedPipeServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
560+ http.Error(w, "unauthorized", http.StatusUnauthorized)
561+ }))
562+ conn := ln.dial()
563+ defer conn.Close()
564+ go func() { _, _ = io.Copy(io.Discard, conn) }() // read the response, then stay silent
565+ _, err := conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n"))
566+ require.NoError(t, err)
567+
568+ requireClosed(t, closed, "drain of an unread declared request body")
569+}
570+
571+func TestTransferIdleTimeout_UnreadDeclaredBodyDrainIsBoundedOverTCP(t *testing.T) {
572+ closed := make(chan struct{})
573+ var once sync.Once
574+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
575+ http.Error(w, "unauthorized", http.StatusUnauthorized)
576+ })))
577+ srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
578+ if state == http.StateClosed {
579+ once.Do(func() { close(closed) })
580+ }
581+ }
582+ srv.Start()
583+ defer srv.Close()
584+
585+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
586+ require.NoError(t, err)
587+ defer conn.Close()
588+ _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n"))
589+ require.NoError(t, err)
590+
591+ requireClosed(t, closed, "drain of an unread declared request body over TCP")
592+}
593+
594+// Leftover deadlines armed at handler return must not leak into the next
595+// keep-alive request on the same connection.
596+func TestTransferIdleTimeout_KeepAliveNextRequestNotAffectedByLeftoverDeadlines(t *testing.T) {
597+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
598+ _, _ = io.Copy(io.Discard, r.Body)
599+ _, _ = w.Write([]byte("ok"))
600+ })))
601+ srv.Config.IdleTimeout = 10 * time.Second
602+ srv.Start()
603+ defer srv.Close()
604+
605+ client := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 1}}
606+ for i := range 3 {
607+ resp, err := client.Post(srv.URL, "text/plain", strings.NewReader("body"))
608+ require.NoError(t, err, "request %d", i)
609+ _, err = io.ReadAll(resp.Body)
610+ require.NoError(t, err)
611+ _ = resp.Body.Close()
612+ time.Sleep(2 * testIdle) // outlive the leftover deadline while the connection idles
613+ }
614+}
615+
616+// Hijack flushes pending headers; it must not hold the timer's mutex while it
617+// blocks on a client that does not read.
618+func TestTransferIdleTimeout_HijackBlockedOnNonReadingClientIsBounded(t *testing.T) {
619+ hijackErr := make(chan error, 1)
620+ ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
621+ w.WriteHeader(http.StatusOK)
622+ conn, _, err := w.(http.Hijacker).Hijack()
623+ if conn != nil {
624+ _ = conn.Close()
625+ }
626+ hijackErr <- err
627+ }))
628+ conn := ln.dial()
629+ defer conn.Close()
630+ _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n"))
631+ require.NoError(t, err)
632+
633+ select {
634+ case <-hijackErr:
635+ case <-time.After(20 * testIdle):
636+ t.Fatal("Hijack blocked on a non-reading client was not bounded")
637+ }
638+}
A internal/adapters/in/traffic/http_server.go +31 -0
@@ -0,0 +1,31 @@
1+package traffic
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+// httpOptions returns the effective HTTP server options of the current traffic
11+// snapshot, falling back to the defaults before the first graph is applied.
12+func (m *Manager) httpOptions() domain.HTTPOptions {
13+ graph := m.snapshot.Load()
14+ if graph == nil {
15+ return domain.DefaultHTTPOptions()
16+ }
17+ return graph.Options.HTTP.WithDefaults()
18+}
19+
20+// newEntryPointHTTPServer builds an HTTP server for a traffic entrypoint.
21+// ReadTimeout and WriteTimeout stay zero: whole-request deadlines would cut
22+// long downloads and uploads that are still moving data. The handler is
23+// wrapped with the inactivity timeout instead, which renews the read and write
24+// deadlines as data flows.
25+func newEntryPointHTTPServer(handler http.Handler, options domain.HTTPOptions) *http.Server {
26+ return &http.Server{
27+ Handler: middleware.TransferIdleTimeout(options.TransferIdleTimeout)(handler),
28+ ReadHeaderTimeout: options.ReadHeaderTimeout,
29+ IdleTimeout: options.IdleTimeout,
30+ }
31+}
@@ -0,0 +1,31 @@
1+package traffic
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+// httpOptions returns the effective HTTP server options of the current traffic
11+// snapshot, falling back to the defaults before the first graph is applied.
12+func (m *Manager) httpOptions() domain.HTTPOptions {
13+ graph := m.snapshot.Load()
14+ if graph == nil {
15+ return domain.DefaultHTTPOptions()
16+ }
17+ return graph.Options.HTTP.WithDefaults()
18+}
19+
20+// newEntryPointHTTPServer builds an HTTP server for a traffic entrypoint.
21+// ReadTimeout and WriteTimeout stay zero: whole-request deadlines would cut
22+// long downloads and uploads that are still moving data. The handler is
23+// wrapped with the inactivity timeout instead, which renews the read and write
24+// deadlines as data flows.
25+func newEntryPointHTTPServer(handler http.Handler, options domain.HTTPOptions) *http.Server {
26+ return &http.Server{
27+ Handler: middleware.TransferIdleTimeout(options.TransferIdleTimeout)(handler),
28+ ReadHeaderTimeout: options.ReadHeaderTimeout,
29+ IdleTimeout: options.IdleTimeout,
30+ }
31+}
A internal/adapters/in/traffic/http_server_test.go +125 -0
@@ -0,0 +1,125 @@
1+package traffic
2+
3+import (
4+ "bufio"
5+ "context"
6+ "io"
7+ "net/http"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func TestNewEntryPointHTTPServerUsesConfiguredTimeouts(t *testing.T) {
18+ server := newEntryPointHTTPServer(http.NotFoundHandler(), domain.HTTPOptions{
19+ ReadHeaderTimeout: 3 * time.Second,
20+ IdleTimeout: 7 * time.Second,
21+ TransferIdleTimeout: 9 * time.Second,
22+ })
23+
24+ assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout)
25+ assert.Equal(t, 7*time.Second, server.IdleTimeout)
26+ assert.Zero(t, server.ReadTimeout, "whole-request read timeout must be replaced by the inactivity deadline")
27+ assert.Zero(t, server.WriteTimeout, "whole-response write timeout must be replaced by the inactivity deadline")
28+}
29+
30+func TestManagerHTTPOptionsDefaultsWithoutSnapshot(t *testing.T) {
31+ assert.Equal(t, domain.DefaultHTTPOptions(), NewManager().httpOptions())
32+}
33+
34+func TestEntryPointServersReceiveConfiguredHTTPOptions(t *testing.T) {
35+ manager := NewManager()
36+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
37+ graph.Options.HTTP = domain.HTTPOptions{ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 7 * time.Second, TransferIdleTimeout: 9 * time.Second}
38+ manager.SetSmartTCPHTTPServer("edge", http.NotFoundHandler(), nil)
39+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
40+ require.NoError(t, manager.Apply(context.Background(), &graph))
41+ defer shutdownManager(t, manager)
42+
43+ runtime := tcpRuntimeForTest(t, manager, "edge")
44+ require.NotNil(t, runtime)
45+ runtime.mu.Lock()
46+ plain, secure := runtime.smartHTTPServer, runtime.smartTLSServer
47+ runtime.mu.Unlock()
48+ for name, server := range map[string]*http.Server{"plain": plain, "tls": secure} {
49+ require.NotNil(t, server, name)
50+ assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout, name)
51+ assert.Equal(t, 7*time.Second, server.IdleTimeout, name)
52+ assert.Zero(t, server.ReadTimeout, name)
53+ assert.Zero(t, server.WriteTimeout, name)
54+ }
55+}
56+
57+func TestTLSMuxServerReceivesConfiguredHTTPOptions(t *testing.T) {
58+ manager := NewManager()
59+ graph := domain.TrafficGraph{
60+ Options: domain.TrafficOptions{HTTP: domain.HTTPOptions{ReadHeaderTimeout: 4 * time.Second, IdleTimeout: 8 * time.Second, TransferIdleTimeout: time.Minute}},
61+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}},
62+ }
63+ manager.SetTLSHTTPServer("websecure", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
64+ require.NoError(t, manager.Apply(context.Background(), &graph))
65+ defer shutdownManager(t, manager)
66+
67+ runtime := tcpRuntimeForTest(t, manager, "websecure")
68+ require.NotNil(t, runtime)
69+ runtime.mu.Lock()
70+ server := runtime.tlsHTTPServer
71+ runtime.mu.Unlock()
72+ require.NotNil(t, server)
73+ assert.Equal(t, 4*time.Second, server.ReadHeaderTimeout)
74+ assert.Equal(t, 8*time.Second, server.IdleTimeout)
75+ assert.Zero(t, server.ReadTimeout)
76+ assert.Zero(t, server.WriteTimeout)
77+}
78+
79+func TestSmartTCPStreamingResponseOutlivesTransferIdleTimeout(t *testing.T) {
80+ const idle = 300 * time.Millisecond
81+ manager := NewManager()
82+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
83+ graph.Options.HTTP = domain.HTTPOptions{TransferIdleTimeout: idle}
84+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
85+ silent := r.URL.Path == "/silent"
86+ for i := 0; i < 12; i++ {
87+ if _, err := w.Write([]byte("x")); err != nil {
88+ return
89+ }
90+ w.(http.Flusher).Flush()
91+ if silent {
92+ // Stay silent until the inactivity timeout cancels the request.
93+ <-r.Context().Done()
94+ return
95+ }
96+ select {
97+ case <-r.Context().Done():
98+ return
99+ case <-time.After(50 * time.Millisecond):
100+ }
101+ }
102+ }), nil)
103+ require.NoError(t, manager.Apply(context.Background(), &graph))
104+ defer shutdownManager(t, manager)
105+
106+ get := func(path string) (int, error) {
107+ conn := dialTCP(t, graph.EntryPoints[0].Address)
108+ defer conn.Close()
109+ _, err := conn.Write([]byte("GET " + path + " HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
110+ require.NoError(t, err)
111+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
112+ require.NoError(t, err)
113+ defer resp.Body.Close()
114+ body, err := io.ReadAll(resp.Body)
115+ return len(body), err
116+ }
117+
118+ n, err := get("/active")
119+ require.NoError(t, err)
120+ assert.Equal(t, 12, n, "active transfer longer than the idle timeout must complete")
121+
122+ n, err = get("/silent")
123+ require.Error(t, err, "silent transfer must be cut")
124+ assert.Less(t, n, 12)
125+}
@@ -0,0 +1,125 @@
1+package traffic
2+
3+import (
4+ "bufio"
5+ "context"
6+ "io"
7+ "net/http"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func TestNewEntryPointHTTPServerUsesConfiguredTimeouts(t *testing.T) {
18+ server := newEntryPointHTTPServer(http.NotFoundHandler(), domain.HTTPOptions{
19+ ReadHeaderTimeout: 3 * time.Second,
20+ IdleTimeout: 7 * time.Second,
21+ TransferIdleTimeout: 9 * time.Second,
22+ })
23+
24+ assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout)
25+ assert.Equal(t, 7*time.Second, server.IdleTimeout)
26+ assert.Zero(t, server.ReadTimeout, "whole-request read timeout must be replaced by the inactivity deadline")
27+ assert.Zero(t, server.WriteTimeout, "whole-response write timeout must be replaced by the inactivity deadline")
28+}
29+
30+func TestManagerHTTPOptionsDefaultsWithoutSnapshot(t *testing.T) {
31+ assert.Equal(t, domain.DefaultHTTPOptions(), NewManager().httpOptions())
32+}
33+
34+func TestEntryPointServersReceiveConfiguredHTTPOptions(t *testing.T) {
35+ manager := NewManager()
36+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
37+ graph.Options.HTTP = domain.HTTPOptions{ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 7 * time.Second, TransferIdleTimeout: 9 * time.Second}
38+ manager.SetSmartTCPHTTPServer("edge", http.NotFoundHandler(), nil)
39+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
40+ require.NoError(t, manager.Apply(context.Background(), &graph))
41+ defer shutdownManager(t, manager)
42+
43+ runtime := tcpRuntimeForTest(t, manager, "edge")
44+ require.NotNil(t, runtime)
45+ runtime.mu.Lock()
46+ plain, secure := runtime.smartHTTPServer, runtime.smartTLSServer
47+ runtime.mu.Unlock()
48+ for name, server := range map[string]*http.Server{"plain": plain, "tls": secure} {
49+ require.NotNil(t, server, name)
50+ assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout, name)
51+ assert.Equal(t, 7*time.Second, server.IdleTimeout, name)
52+ assert.Zero(t, server.ReadTimeout, name)
53+ assert.Zero(t, server.WriteTimeout, name)
54+ }
55+}
56+
57+func TestTLSMuxServerReceivesConfiguredHTTPOptions(t *testing.T) {
58+ manager := NewManager()
59+ graph := domain.TrafficGraph{
60+ Options: domain.TrafficOptions{HTTP: domain.HTTPOptions{ReadHeaderTimeout: 4 * time.Second, IdleTimeout: 8 * time.Second, TransferIdleTimeout: time.Minute}},
61+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}},
62+ }
63+ manager.SetTLSHTTPServer("websecure", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
64+ require.NoError(t, manager.Apply(context.Background(), &graph))
65+ defer shutdownManager(t, manager)
66+
67+ runtime := tcpRuntimeForTest(t, manager, "websecure")
68+ require.NotNil(t, runtime)
69+ runtime.mu.Lock()
70+ server := runtime.tlsHTTPServer
71+ runtime.mu.Unlock()
72+ require.NotNil(t, server)
73+ assert.Equal(t, 4*time.Second, server.ReadHeaderTimeout)
74+ assert.Equal(t, 8*time.Second, server.IdleTimeout)
75+ assert.Zero(t, server.ReadTimeout)
76+ assert.Zero(t, server.WriteTimeout)
77+}
78+
79+func TestSmartTCPStreamingResponseOutlivesTransferIdleTimeout(t *testing.T) {
80+ const idle = 300 * time.Millisecond
81+ manager := NewManager()
82+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
83+ graph.Options.HTTP = domain.HTTPOptions{TransferIdleTimeout: idle}
84+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
85+ silent := r.URL.Path == "/silent"
86+ for i := 0; i < 12; i++ {
87+ if _, err := w.Write([]byte("x")); err != nil {
88+ return
89+ }
90+ w.(http.Flusher).Flush()
91+ if silent {
92+ // Stay silent until the inactivity timeout cancels the request.
93+ <-r.Context().Done()
94+ return
95+ }
96+ select {
97+ case <-r.Context().Done():
98+ return
99+ case <-time.After(50 * time.Millisecond):
100+ }
101+ }
102+ }), nil)
103+ require.NoError(t, manager.Apply(context.Background(), &graph))
104+ defer shutdownManager(t, manager)
105+
106+ get := func(path string) (int, error) {
107+ conn := dialTCP(t, graph.EntryPoints[0].Address)
108+ defer conn.Close()
109+ _, err := conn.Write([]byte("GET " + path + " HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
110+ require.NoError(t, err)
111+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
112+ require.NoError(t, err)
113+ defer resp.Body.Close()
114+ body, err := io.ReadAll(resp.Body)
115+ return len(body), err
116+ }
117+
118+ n, err := get("/active")
119+ require.NoError(t, err)
120+ assert.Equal(t, 12, n, "active transfer longer than the idle timeout must complete")
121+
122+ n, err = get("/silent")
123+ require.Error(t, err, "silent transfer must be cut")
124+ assert.Less(t, n, 12)
125+}
M internal/adapters/in/traffic/smart_tcp.go +4 -2
@@ -151,7 +151,8 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi
151151 return
152152 }
153153 listener := newTLSHTTPListener(r.listener.Addr())
154- server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
154+ server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions())
155+ server.Protocols = config.Protocols
155156 done := make(chan struct{})
156157 r.mu.Lock()
157158 oldListener := r.smartHTTPListener
@@ -182,7 +183,8 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin
182183 return
183184 }
184185 listener := newTLSHTTPListener(r.listener.Addr())
185- server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
186+ server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions())
187+ server.TLSConfig = config.TLSConfig.Clone()
186188 done := make(chan struct{})
187189 r.mu.Lock()
188190 oldListener := r.smartTLSListener
@@ -151,7 +151,8 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi
151 return151 return
152 }152 }
153 listener := newTLSHTTPListener(r.listener.Addr())153 listener := newTLSHTTPListener(r.listener.Addr())
154- server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}154+ server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions())
155+ server.Protocols = config.Protocols
155 done := make(chan struct{})156 done := make(chan struct{})
156 r.mu.Lock()157 r.mu.Lock()
157 oldListener := r.smartHTTPListener158 oldListener := r.smartHTTPListener
@@ -182,7 +183,8 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin
182 return183 return
183 }184 }
184 listener := newTLSHTTPListener(r.listener.Addr())185 listener := newTLSHTTPListener(r.listener.Addr())
185- server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}186+ server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions())
187+ server.TLSConfig = config.TLSConfig.Clone()
186 done := make(chan struct{})188 done := make(chan struct{})
187 r.mu.Lock()189 r.mu.Lock()
188 oldListener := r.smartTLSListener190 oldListener := r.smartTLSListener
M internal/adapters/in/traffic/tcp.go +2 -8
@@ -169,14 +169,8 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c
169169 return
170170 }
171171 listener := newTLSHTTPListener(r.listener.Addr())
172- server := &http.Server{
173- Handler: config.Handler,
174- TLSConfig: config.TLSConfig.Clone(),
175- ReadHeaderTimeout: 5 * time.Second,
176- ReadTimeout: 10 * time.Second,
177- WriteTimeout: 10 * time.Second,
178- IdleTimeout: 30 * time.Second,
179- }
172+ server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions())
173+ server.TLSConfig = config.TLSConfig.Clone()
180174 done := make(chan struct{})
181175
182176 r.mu.Lock()
@@ -169,14 +169,8 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c
169 return169 return
170 }170 }
171 listener := newTLSHTTPListener(r.listener.Addr())171 listener := newTLSHTTPListener(r.listener.Addr())
172- server := &http.Server{172+ server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions())
173- Handler: config.Handler,173+ server.TLSConfig = config.TLSConfig.Clone()
174- TLSConfig: config.TLSConfig.Clone(),
175- ReadHeaderTimeout: 5 * time.Second,
176- ReadTimeout: 10 * time.Second,
177- WriteTimeout: 10 * time.Second,
178- IdleTimeout: 30 * time.Second,
179- }
180 done := make(chan struct{})174 done := make(chan struct{})
181 175
182 r.mu.Lock()176 r.mu.Lock()
M internal/app/config.go +79 -0
@@ -5,6 +5,7 @@ import (
55 "os"
66 "path/filepath"
77 "strings"
8+ "time"
89
910 "github.com/bnema/zerowrap"
1011 "github.com/spf13/viper"
@@ -37,6 +38,14 @@ type Config struct {
3738 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
3839 ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
3940 RegistryListenAddr string `mapstructure:"registry_listen_address"`
41+
42+ // HTTP holds the registry HTTP server timeouts (Go duration strings).
43+ HTTP struct {
44+ ReadHeaderTimeout string `mapstructure:"read_header_timeout"`
45+ ReadTimeout string `mapstructure:"read_timeout"`
46+ WriteTimeout string `mapstructure:"write_timeout"`
47+ IdleTimeout string `mapstructure:"idle_timeout"`
48+ } `mapstructure:"http"`
4049 } `mapstructure:"server"`
4150
4251 Apps struct {
@@ -225,6 +234,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
225234 if err := validateRetiredAppConfig(v); err != nil {
226235 return nil, Config{}, err
227236 }
237+ if err := validateHTTPTimeouts(cfg); err != nil {
238+ return nil, Config{}, err
239+ }
228240 if _, err := buildAppMountPolicies(cfg); err != nil {
229241 return nil, Config{}, err
230242 }
@@ -439,6 +451,22 @@ func loadConfig(v *viper.Viper, configPath string) error {
439451 v.SetDefault("server.proxy_allowed_ips", []string{})
440452 v.SetDefault("server.registry_listen_address", "")
441453
454+ // HTTP timeouts. Every key needs a default so GORDON_* env overrides apply.
455+ v.SetDefault("server.http.read_header_timeout", domain.DefaultRegistryReadHeaderTimeout.String())
456+ v.SetDefault("server.http.read_timeout", domain.DefaultRegistryReadTimeout.String())
457+ v.SetDefault("server.http.write_timeout", domain.DefaultRegistryWriteTimeout.String())
458+ v.SetDefault("server.http.idle_timeout", domain.DefaultRegistryIdleTimeout.String())
459+ v.SetDefault("traffic.http.read_header_timeout", domain.DefaultEntryPointReadHeaderTimeout.String())
460+ v.SetDefault("traffic.http.idle_timeout", domain.DefaultEntryPointIdleTimeout.String())
461+ v.SetDefault("traffic.http.transfer_idle_timeout", domain.DefaultEntryPointTransferIdleTimeout.String())
462+ v.SetDefault("traffic.tcp.dial_timeout", "10s")
463+ v.SetDefault("traffic.tcp.idle_timeout", "5m")
464+ v.SetDefault("traffic.tcp.drain_timeout", "30s")
465+ v.SetDefault("traffic.udp.idle_timeout", "30s")
466+ v.SetDefault("traffic.udp.drain_timeout", "30s")
467+ v.SetDefault("traffic.tcp.max_connections", 0) // 0 = runtime default
468+ v.SetDefault("traffic.udp.max_sessions", 0) // 0 = runtime default
469+
442470 ConfigureViper(v, configPath)
443471
444472 if err := v.ReadInConfig(); err != nil {
@@ -453,3 +481,54 @@ func loadConfig(v *viper.Viper, configPath string) error {
453481
454482 return nil
455483 }
484+
485+// validateHTTPTimeouts rejects malformed, zero, or negative HTTP timeouts at
486+// config load, before any listener is started.
487+func validateHTTPTimeouts(cfg Config) error {
488+ if _, err := buildRegistryHTTPTimeouts(cfg); err != nil {
489+ return err
490+ }
491+ if _, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP); err != nil {
492+ return err
493+ }
494+ return nil
495+}
496+
497+// buildRegistryHTTPTimeouts parses server.http into validated registry server
498+// timeouts. Empty values select the defaults; malformed, zero, or negative
499+// values are rejected.
500+func buildRegistryHTTPTimeouts(cfg Config) (domain.RegistryHTTPTimeouts, error) {
501+ defaults := domain.DefaultRegistryHTTPTimeouts()
502+ var (
503+ t domain.RegistryHTTPTimeouts
504+ err error
505+ )
506+ if t.ReadHeaderTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "server.http.read_header_timeout"); err != nil {
507+ return t, err
508+ }
509+ if t.ReadTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadTimeout, defaults.ReadTimeout, "server.http.read_timeout"); err != nil {
510+ return t, err
511+ }
512+ if t.WriteTimeout, err = parsePositiveDuration(cfg.Server.HTTP.WriteTimeout, defaults.WriteTimeout, "server.http.write_timeout"); err != nil {
513+ return t, err
514+ }
515+ if t.IdleTimeout, err = parsePositiveDuration(cfg.Server.HTTP.IdleTimeout, defaults.IdleTimeout, "server.http.idle_timeout"); err != nil {
516+ return t, err
517+ }
518+ return t, t.Validate()
519+}
520+
521+func parsePositiveDuration(value string, fallback time.Duration, key string) (time.Duration, error) {
522+ value = strings.TrimSpace(value)
523+ if value == "" {
524+ return fallback, nil
525+ }
526+ d, err := time.ParseDuration(value)
527+ if err != nil {
528+ return 0, fmt.Errorf("invalid %s: %w", key, err)
529+ }
530+ if d <= 0 {
531+ return 0, fmt.Errorf("invalid %s: must be positive", key)
532+ }
533+ return d, nil
534+}
@@ -5,6 +5,7 @@ import (
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7 "strings"7 "strings"
8+ "time"
8 9
9 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
10 "github.com/spf13/viper"11 "github.com/spf13/viper"
@@ -37,6 +38,14 @@ type Config struct {
37 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`38 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
38 ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`39 ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
39 RegistryListenAddr string `mapstructure:"registry_listen_address"`40 RegistryListenAddr string `mapstructure:"registry_listen_address"`
41+
42+ // HTTP holds the registry HTTP server timeouts (Go duration strings).
43+ HTTP struct {
44+ ReadHeaderTimeout string `mapstructure:"read_header_timeout"`
45+ ReadTimeout string `mapstructure:"read_timeout"`
46+ WriteTimeout string `mapstructure:"write_timeout"`
47+ IdleTimeout string `mapstructure:"idle_timeout"`
48+ } `mapstructure:"http"`
40 } `mapstructure:"server"`49 } `mapstructure:"server"`
41 50
42 Apps struct {51 Apps struct {
@@ -225,6 +234,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
225 if err := validateRetiredAppConfig(v); err != nil {234 if err := validateRetiredAppConfig(v); err != nil {
226 return nil, Config{}, err235 return nil, Config{}, err
227 }236 }
237+ if err := validateHTTPTimeouts(cfg); err != nil {
238+ return nil, Config{}, err
239+ }
228 if _, err := buildAppMountPolicies(cfg); err != nil {240 if _, err := buildAppMountPolicies(cfg); err != nil {
229 return nil, Config{}, err241 return nil, Config{}, err
230 }242 }
@@ -439,6 +451,22 @@ func loadConfig(v *viper.Viper, configPath string) error {
439 v.SetDefault("server.proxy_allowed_ips", []string{})451 v.SetDefault("server.proxy_allowed_ips", []string{})
440 v.SetDefault("server.registry_listen_address", "")452 v.SetDefault("server.registry_listen_address", "")
441 453
454+ // HTTP timeouts. Every key needs a default so GORDON_* env overrides apply.
455+ v.SetDefault("server.http.read_header_timeout", domain.DefaultRegistryReadHeaderTimeout.String())
456+ v.SetDefault("server.http.read_timeout", domain.DefaultRegistryReadTimeout.String())
457+ v.SetDefault("server.http.write_timeout", domain.DefaultRegistryWriteTimeout.String())
458+ v.SetDefault("server.http.idle_timeout", domain.DefaultRegistryIdleTimeout.String())
459+ v.SetDefault("traffic.http.read_header_timeout", domain.DefaultEntryPointReadHeaderTimeout.String())
460+ v.SetDefault("traffic.http.idle_timeout", domain.DefaultEntryPointIdleTimeout.String())
461+ v.SetDefault("traffic.http.transfer_idle_timeout", domain.DefaultEntryPointTransferIdleTimeout.String())
462+ v.SetDefault("traffic.tcp.dial_timeout", "10s")
463+ v.SetDefault("traffic.tcp.idle_timeout", "5m")
464+ v.SetDefault("traffic.tcp.drain_timeout", "30s")
465+ v.SetDefault("traffic.udp.idle_timeout", "30s")
466+ v.SetDefault("traffic.udp.drain_timeout", "30s")
467+ v.SetDefault("traffic.tcp.max_connections", 0) // 0 = runtime default
468+ v.SetDefault("traffic.udp.max_sessions", 0) // 0 = runtime default
469+
442 ConfigureViper(v, configPath)470 ConfigureViper(v, configPath)
443 471
444 if err := v.ReadInConfig(); err != nil {472 if err := v.ReadInConfig(); err != nil {
@@ -453,3 +481,54 @@ func loadConfig(v *viper.Viper, configPath string) error {
453 481
454 return nil482 return nil
455 }483 }
484+
485+// validateHTTPTimeouts rejects malformed, zero, or negative HTTP timeouts at
486+// config load, before any listener is started.
487+func validateHTTPTimeouts(cfg Config) error {
488+ if _, err := buildRegistryHTTPTimeouts(cfg); err != nil {
489+ return err
490+ }
491+ if _, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP); err != nil {
492+ return err
493+ }
494+ return nil
495+}
496+
497+// buildRegistryHTTPTimeouts parses server.http into validated registry server
498+// timeouts. Empty values select the defaults; malformed, zero, or negative
499+// values are rejected.
500+func buildRegistryHTTPTimeouts(cfg Config) (domain.RegistryHTTPTimeouts, error) {
501+ defaults := domain.DefaultRegistryHTTPTimeouts()
502+ var (
503+ t domain.RegistryHTTPTimeouts
504+ err error
505+ )
506+ if t.ReadHeaderTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "server.http.read_header_timeout"); err != nil {
507+ return t, err
508+ }
509+ if t.ReadTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadTimeout, defaults.ReadTimeout, "server.http.read_timeout"); err != nil {
510+ return t, err
511+ }
512+ if t.WriteTimeout, err = parsePositiveDuration(cfg.Server.HTTP.WriteTimeout, defaults.WriteTimeout, "server.http.write_timeout"); err != nil {
513+ return t, err
514+ }
515+ if t.IdleTimeout, err = parsePositiveDuration(cfg.Server.HTTP.IdleTimeout, defaults.IdleTimeout, "server.http.idle_timeout"); err != nil {
516+ return t, err
517+ }
518+ return t, t.Validate()
519+}
520+
521+func parsePositiveDuration(value string, fallback time.Duration, key string) (time.Duration, error) {
522+ value = strings.TrimSpace(value)
523+ if value == "" {
524+ return fallback, nil
525+ }
526+ d, err := time.ParseDuration(value)
527+ if err != nil {
528+ return 0, fmt.Errorf("invalid %s: %w", key, err)
529+ }
530+ if d <= 0 {
531+ return 0, fmt.Errorf("invalid %s: must be positive", key)
532+ }
533+ return d, nil
534+}
A internal/app/http_timeouts_config_test.go +173 -0
@@ -0,0 +1,173 @@
1+package app
2+
3+import (
4+ "net/http"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/internal/usecase/traffic"
16+)
17+
18+func writeTimeoutConfig(t *testing.T, body string) string {
19+ t.Helper()
20+ path := filepath.Join(t.TempDir(), "gordon.toml")
21+ require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
22+ return path
23+}
24+
25+func TestInitConfigHTTPTimeoutDefaults(t *testing.T) {
26+ _, cfg, err := initConfig(writeTimeoutConfig(t, ""))
27+ require.NoError(t, err)
28+
29+ registry, err := buildRegistryHTTPTimeouts(cfg)
30+ require.NoError(t, err)
31+ assert.Equal(t, domain.RegistryHTTPTimeouts{
32+ ReadHeaderTimeout: 10 * time.Second,
33+ ReadTimeout: 5 * time.Minute,
34+ WriteTimeout: 5 * time.Minute,
35+ IdleTimeout: 120 * time.Second,
36+ }, registry)
37+
38+ entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP)
39+ require.NoError(t, err)
40+ assert.Equal(t, domain.HTTPOptions{
41+ ReadHeaderTimeout: 5 * time.Second,
42+ IdleTimeout: 30 * time.Second,
43+ TransferIdleTimeout: 2 * time.Minute,
44+ }, entry)
45+}
46+
47+func TestInitConfigHTTPTimeoutFileOverride(t *testing.T) {
48+ path := writeTimeoutConfig(t, `
49+[server.http]
50+read_header_timeout = "3s"
51+read_timeout = "1m"
52+write_timeout = "2m"
53+idle_timeout = "45s"
54+
55+[traffic.http]
56+read_header_timeout = "4s"
57+idle_timeout = "15s"
58+transfer_idle_timeout = "10m"
59+`)
60+ _, cfg, err := initConfig(path)
61+ require.NoError(t, err)
62+
63+ registry, err := buildRegistryHTTPTimeouts(cfg)
64+ require.NoError(t, err)
65+ assert.Equal(t, 3*time.Second, registry.ReadHeaderTimeout)
66+ assert.Equal(t, time.Minute, registry.ReadTimeout)
67+ assert.Equal(t, 2*time.Minute, registry.WriteTimeout)
68+ assert.Equal(t, 45*time.Second, registry.IdleTimeout)
69+
70+ entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP)
71+ require.NoError(t, err)
72+ assert.Equal(t, 4*time.Second, entry.ReadHeaderTimeout)
73+ assert.Equal(t, 15*time.Second, entry.IdleTimeout)
74+ assert.Equal(t, 10*time.Minute, entry.TransferIdleTimeout)
75+}
76+
77+func TestInitConfigHTTPTimeoutEnvOverride(t *testing.T) {
78+ t.Setenv("GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT", "7m")
79+ t.Setenv("GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT", "6s")
80+ t.Setenv("GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT", "11s")
81+ t.Setenv("GORDON_SERVER_HTTP_WRITE_TIMEOUT", "9m")
82+ t.Setenv("GORDON_SERVER_HTTP_READ_TIMEOUT", "8m")
83+ t.Setenv("GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT", "12s")
84+ t.Setenv("GORDON_SERVER_HTTP_IDLE_TIMEOUT", "50s")
85+ t.Setenv("GORDON_TRAFFIC_TCP_DIAL_TIMEOUT", "3s")
86+
87+ path := writeTimeoutConfig(t, "[traffic.http]\ntransfer_idle_timeout = \"1m\"\n")
88+ _, cfg, err := initConfig(path)
89+ require.NoError(t, err)
90+
91+ entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP)
92+ require.NoError(t, err)
93+ assert.Equal(t, 7*time.Minute, entry.TransferIdleTimeout, "env wins over file")
94+ assert.Equal(t, 6*time.Second, entry.ReadHeaderTimeout)
95+ assert.Equal(t, 11*time.Second, entry.IdleTimeout)
96+
97+ registry, err := buildRegistryHTTPTimeouts(cfg)
98+ require.NoError(t, err)
99+ assert.Equal(t, 9*time.Minute, registry.WriteTimeout)
100+ assert.Equal(t, 8*time.Minute, registry.ReadTimeout)
101+ assert.Equal(t, 12*time.Second, registry.ReadHeaderTimeout)
102+ assert.Equal(t, 50*time.Second, registry.IdleTimeout)
103+
104+ // Existing traffic.tcp keys are overridable too (they need a default).
105+ assert.Equal(t, "3s", cfg.Traffic.TCP.DialTimeout)
106+}
107+
108+func TestInitConfigRejectsInvalidHTTPTimeouts(t *testing.T) {
109+ tests := []struct {
110+ name string
111+ body string
112+ key string
113+ }{
114+ {"malformed registry", "[server.http]\nread_timeout = \"soon\"\n", "server.http.read_timeout"},
115+ {"negative registry", "[server.http]\nwrite_timeout = \"-5s\"\n", "server.http.write_timeout"},
116+ {"zero registry", "[server.http]\nidle_timeout = \"0s\"\n", "server.http.idle_timeout"},
117+ {"malformed traffic", "[traffic.http]\nread_header_timeout = \"x\"\n", "traffic.http.read_header_timeout"},
118+ {"negative traffic", "[traffic.http]\ntransfer_idle_timeout = \"-1m\"\n", "traffic.http.transfer_idle_timeout"},
119+ {"zero traffic", "[traffic.http]\nidle_timeout = \"0\"\n", "traffic.http.idle_timeout"},
120+ }
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
123+ _, _, err := initConfig(writeTimeoutConfig(t, tt.body))
124+ require.Error(t, err)
125+ assert.ErrorContains(t, err, tt.key)
126+ })
127+ }
128+}
129+
130+func TestInitConfigRejectsInvalidHTTPTimeoutFromEnv(t *testing.T) {
131+ t.Setenv("GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT", "-3s")
132+ _, _, err := initConfig(writeTimeoutConfig(t, ""))
133+ require.ErrorContains(t, err, "traffic.http.transfer_idle_timeout")
134+}
135+
136+func TestStartServerAppliesConfiguredTimeouts(t *testing.T) {
137+ timeouts := domain.RegistryHTTPTimeouts{
138+ ReadHeaderTimeout: 1 * time.Second,
139+ ReadTimeout: 2 * time.Second,
140+ WriteTimeout: 3 * time.Second,
141+ IdleTimeout: 4 * time.Second,
142+ }
143+ errChan := make(chan error, 1)
144+ srv, ready := startServer("127.0.0.1:0", http.NotFoundHandler(), "test", nil, timeouts, errChan, zerowrap.Default())
145+ <-ready
146+ defer func() { _ = srv.Close() }()
147+
148+ assert.Equal(t, timeouts.ReadHeaderTimeout, srv.ReadHeaderTimeout)
149+ assert.Equal(t, timeouts.ReadTimeout, srv.ReadTimeout)
150+ assert.Equal(t, timeouts.WriteTimeout, srv.WriteTimeout)
151+ assert.Equal(t, timeouts.IdleTimeout, srv.IdleTimeout)
152+}
153+
154+func TestStartServerFallsBackToDefaultTimeouts(t *testing.T) {
155+ errChan := make(chan error, 1)
156+ srv, ready := startServer("127.0.0.1:0", http.NotFoundHandler(), "test", nil, domain.RegistryHTTPTimeouts{}, errChan, zerowrap.Default())
157+ <-ready
158+ defer func() { _ = srv.Close() }()
159+
160+ assert.Equal(t, domain.DefaultRegistryHTTPTimeouts(), domain.RegistryHTTPTimeouts{
161+ ReadHeaderTimeout: srv.ReadHeaderTimeout,
162+ ReadTimeout: srv.ReadTimeout,
163+ WriteTimeout: srv.WriteTimeout,
164+ IdleTimeout: srv.IdleTimeout,
165+ })
166+}
167+
168+func TestStartRegistryServersRejectsInvalidTimeouts(t *testing.T) {
169+ cfg := Config{}
170+ cfg.Server.HTTP.ReadTimeout = "-1s"
171+ _, _, _, _, err := startRegistryServers(cfg, http.NotFoundHandler(), make(chan error, 1), zerowrap.Default())
172+ require.ErrorContains(t, err, "server.http.read_timeout")
173+}
@@ -0,0 +1,173 @@
1+package app
2+
3+import (
4+ "net/http"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/internal/usecase/traffic"
16+)
17+
18+func writeTimeoutConfig(t *testing.T, body string) string {
19+ t.Helper()
20+ path := filepath.Join(t.TempDir(), "gordon.toml")
21+ require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
22+ return path
23+}
24+
25+func TestInitConfigHTTPTimeoutDefaults(t *testing.T) {
26+ _, cfg, err := initConfig(writeTimeoutConfig(t, ""))
27+ require.NoError(t, err)
28+
29+ registry, err := buildRegistryHTTPTimeouts(cfg)
30+ require.NoError(t, err)
31+ assert.Equal(t, domain.RegistryHTTPTimeouts{
32+ ReadHeaderTimeout: 10 * time.Second,
33+ ReadTimeout: 5 * time.Minute,
34+ WriteTimeout: 5 * time.Minute,
35+ IdleTimeout: 120 * time.Second,
36+ }, registry)
37+
38+ entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP)
39+ require.NoError(t, err)
40+ assert.Equal(t, domain.HTTPOptions{
41+ ReadHeaderTimeout: 5 * time.Second,
42+ IdleTimeout: 30 * time.Second,
43+ TransferIdleTimeout: 2 * time.Minute,
44+ }, entry)
45+}
46+
47+func TestInitConfigHTTPTimeoutFileOverride(t *testing.T) {
48+ path := writeTimeoutConfig(t, `
49+[server.http]
50+read_header_timeout = "3s"
51+read_timeout = "1m"
52+write_timeout = "2m"
53+idle_timeout = "45s"
54+
55+[traffic.http]
56+read_header_timeout = "4s"
57+idle_timeout = "15s"
58+transfer_idle_timeout = "10m"
59+`)
60+ _, cfg, err := initConfig(path)
61+ require.NoError(t, err)
62+
63+ registry, err := buildRegistryHTTPTimeouts(cfg)
64+ require.NoError(t, err)
65+ assert.Equal(t, 3*time.Second, registry.ReadHeaderTimeout)
66+ assert.Equal(t, time.Minute, registry.ReadTimeout)
67+ assert.Equal(t, 2*time.Minute, registry.WriteTimeout)
68+ assert.Equal(t, 45*time.Second, registry.IdleTimeout)
69+
70+ entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP)
71+ require.NoError(t, err)
72+ assert.Equal(t, 4*time.Second, entry.ReadHeaderTimeout)
73+ assert.Equal(t, 15*time.Second, entry.IdleTimeout)
74+ assert.Equal(t, 10*time.Minute, entry.TransferIdleTimeout)
75+}
76+
77+func TestInitConfigHTTPTimeoutEnvOverride(t *testing.T) {
78+ t.Setenv("GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT", "7m")
79+ t.Setenv("GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT", "6s")
80+ t.Setenv("GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT", "11s")
81+ t.Setenv("GORDON_SERVER_HTTP_WRITE_TIMEOUT", "9m")
82+ t.Setenv("GORDON_SERVER_HTTP_READ_TIMEOUT", "8m")
83+ t.Setenv("GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT", "12s")
84+ t.Setenv("GORDON_SERVER_HTTP_IDLE_TIMEOUT", "50s")
85+ t.Setenv("GORDON_TRAFFIC_TCP_DIAL_TIMEOUT", "3s")
86+
87+ path := writeTimeoutConfig(t, "[traffic.http]\ntransfer_idle_timeout = \"1m\"\n")
88+ _, cfg, err := initConfig(path)
89+ require.NoError(t, err)
90+
91+ entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP)
92+ require.NoError(t, err)
93+ assert.Equal(t, 7*time.Minute, entry.TransferIdleTimeout, "env wins over file")
94+ assert.Equal(t, 6*time.Second, entry.ReadHeaderTimeout)
95+ assert.Equal(t, 11*time.Second, entry.IdleTimeout)
96+
97+ registry, err := buildRegistryHTTPTimeouts(cfg)
98+ require.NoError(t, err)
99+ assert.Equal(t, 9*time.Minute, registry.WriteTimeout)
100+ assert.Equal(t, 8*time.Minute, registry.ReadTimeout)
101+ assert.Equal(t, 12*time.Second, registry.ReadHeaderTimeout)
102+ assert.Equal(t, 50*time.Second, registry.IdleTimeout)
103+
104+ // Existing traffic.tcp keys are overridable too (they need a default).
105+ assert.Equal(t, "3s", cfg.Traffic.TCP.DialTimeout)
106+}
107+
108+func TestInitConfigRejectsInvalidHTTPTimeouts(t *testing.T) {
109+ tests := []struct {
110+ name string
111+ body string
112+ key string
113+ }{
114+ {"malformed registry", "[server.http]\nread_timeout = \"soon\"\n", "server.http.read_timeout"},
115+ {"negative registry", "[server.http]\nwrite_timeout = \"-5s\"\n", "server.http.write_timeout"},
116+ {"zero registry", "[server.http]\nidle_timeout = \"0s\"\n", "server.http.idle_timeout"},
117+ {"malformed traffic", "[traffic.http]\nread_header_timeout = \"x\"\n", "traffic.http.read_header_timeout"},
118+ {"negative traffic", "[traffic.http]\ntransfer_idle_timeout = \"-1m\"\n", "traffic.http.transfer_idle_timeout"},
119+ {"zero traffic", "[traffic.http]\nidle_timeout = \"0\"\n", "traffic.http.idle_timeout"},
120+ }
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
123+ _, _, err := initConfig(writeTimeoutConfig(t, tt.body))
124+ require.Error(t, err)
125+ assert.ErrorContains(t, err, tt.key)
126+ })
127+ }
128+}
129+
130+func TestInitConfigRejectsInvalidHTTPTimeoutFromEnv(t *testing.T) {
131+ t.Setenv("GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT", "-3s")
132+ _, _, err := initConfig(writeTimeoutConfig(t, ""))
133+ require.ErrorContains(t, err, "traffic.http.transfer_idle_timeout")
134+}
135+
136+func TestStartServerAppliesConfiguredTimeouts(t *testing.T) {
137+ timeouts := domain.RegistryHTTPTimeouts{
138+ ReadHeaderTimeout: 1 * time.Second,
139+ ReadTimeout: 2 * time.Second,
140+ WriteTimeout: 3 * time.Second,
141+ IdleTimeout: 4 * time.Second,
142+ }
143+ errChan := make(chan error, 1)
144+ srv, ready := startServer("127.0.0.1:0", http.NotFoundHandler(), "test", nil, timeouts, errChan, zerowrap.Default())
145+ <-ready
146+ defer func() { _ = srv.Close() }()
147+
148+ assert.Equal(t, timeouts.ReadHeaderTimeout, srv.ReadHeaderTimeout)
149+ assert.Equal(t, timeouts.ReadTimeout, srv.ReadTimeout)
150+ assert.Equal(t, timeouts.WriteTimeout, srv.WriteTimeout)
151+ assert.Equal(t, timeouts.IdleTimeout, srv.IdleTimeout)
152+}
153+
154+func TestStartServerFallsBackToDefaultTimeouts(t *testing.T) {
155+ errChan := make(chan error, 1)
156+ srv, ready := startServer("127.0.0.1:0", http.NotFoundHandler(), "test", nil, domain.RegistryHTTPTimeouts{}, errChan, zerowrap.Default())
157+ <-ready
158+ defer func() { _ = srv.Close() }()
159+
160+ assert.Equal(t, domain.DefaultRegistryHTTPTimeouts(), domain.RegistryHTTPTimeouts{
161+ ReadHeaderTimeout: srv.ReadHeaderTimeout,
162+ ReadTimeout: srv.ReadTimeout,
163+ WriteTimeout: srv.WriteTimeout,
164+ IdleTimeout: srv.IdleTimeout,
165+ })
166+}
167+
168+func TestStartRegistryServersRejectsInvalidTimeouts(t *testing.T) {
169+ cfg := Config{}
170+ cfg.Server.HTTP.ReadTimeout = "-1s"
171+ _, _, _, _, err := startRegistryServers(cfg, http.NotFoundHandler(), make(chan error, 1), zerowrap.Default())
172+ require.ErrorContains(t, err, "server.http.read_timeout")
173+}
M internal/app/listeners.go +16 -12
@@ -8,7 +8,6 @@ import (
88 "net/http"
99 "strconv"
1010 "strings"
11- "time"
1211
1312 "github.com/bnema/zerowrap"
1413
@@ -353,17 +352,21 @@ func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} {
353352 return names
354353 }
355354
356-func startRegistryServers(cfg Config, handler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}) {
355+func startRegistryServers(cfg Config, handler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
356+ timeouts, err := buildRegistryHTTPTimeouts(cfg)
357+ if err != nil {
358+ return nil, nil, nil, nil, err
359+ }
357360 port := strconv.Itoa(cfg.Server.RegistryPort)
358361 externalAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, port)
359- externalSrv, externalReady := startServer(externalAddr, handler, "registry", nil, errChan, log)
362+ externalSrv, externalReady := startServer(externalAddr, handler, "registry", nil, timeouts, errChan, log)
360363 internalReady := closedReadyChannel()
361364 if !needsInternalRegistryListener(cfg.Server.RegistryListenAddr) {
362- return externalSrv, externalReady, nil, internalReady
365+ return externalSrv, externalReady, nil, internalReady, nil
363366 }
364367 internalAddr := net.JoinHostPort("127.0.0.1", port)
365- internalSrv, internalReady := startServer(internalAddr, handler, "internal registry", nil, errChan, log)
366- return externalSrv, externalReady, internalSrv, internalReady
368+ internalSrv, internalReady := startServer(internalAddr, handler, "internal registry", nil, timeouts, errChan, log)
369+ return externalSrv, externalReady, internalSrv, internalReady, nil
367370 }
368371
369372 func closedReadyChannel() <-chan struct{} {
@@ -384,18 +387,19 @@ func needsInternalRegistryListener(listenAddr string) bool {
384387 // that closes once the listening socket is bound. This lets callers wait for the
385388 // port to be ready before taking actions that depend on it (e.g. auto-start
386389 // pulling from the local registry). The returned *http.Server can be used for
387-// graceful shutdown.
388-func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
390+// graceful shutdown. Timeouts are the configured server.http values.
391+func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, timeouts domain.RegistryHTTPTimeouts, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
392+ timeouts = timeouts.WithDefaults()
389393 ready := make(chan struct{})
390394
391395 server := &http.Server{
392396 Addr: addr,
393397 Handler: handler,
394398 Protocols: protocols,
395- ReadHeaderTimeout: 10 * time.Second,
396- ReadTimeout: 5 * time.Minute,
397- WriteTimeout: 5 * time.Minute,
398- IdleTimeout: 120 * time.Second,
399+ ReadHeaderTimeout: timeouts.ReadHeaderTimeout,
400+ ReadTimeout: timeouts.ReadTimeout,
401+ WriteTimeout: timeouts.WriteTimeout,
402+ IdleTimeout: timeouts.IdleTimeout,
399403 MaxHeaderBytes: 1 << 20,
400404 }
401405
@@ -8,7 +8,6 @@ import (
8 "net/http"8 "net/http"
9 "strconv"9 "strconv"
10 "strings"10 "strings"
11- "time"
12 11
13 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
14 13
@@ -353,17 +352,21 @@ func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} {
353 return names352 return names
354 }353 }
355 354
356-func startRegistryServers(cfg Config, handler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}) {355+func startRegistryServers(cfg Config, handler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
356+ timeouts, err := buildRegistryHTTPTimeouts(cfg)
357+ if err != nil {
358+ return nil, nil, nil, nil, err
359+ }
357 port := strconv.Itoa(cfg.Server.RegistryPort)360 port := strconv.Itoa(cfg.Server.RegistryPort)
358 externalAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, port)361 externalAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, port)
359- externalSrv, externalReady := startServer(externalAddr, handler, "registry", nil, errChan, log)362+ externalSrv, externalReady := startServer(externalAddr, handler, "registry", nil, timeouts, errChan, log)
360 internalReady := closedReadyChannel()363 internalReady := closedReadyChannel()
361 if !needsInternalRegistryListener(cfg.Server.RegistryListenAddr) {364 if !needsInternalRegistryListener(cfg.Server.RegistryListenAddr) {
362- return externalSrv, externalReady, nil, internalReady365+ return externalSrv, externalReady, nil, internalReady, nil
363 }366 }
364 internalAddr := net.JoinHostPort("127.0.0.1", port)367 internalAddr := net.JoinHostPort("127.0.0.1", port)
365- internalSrv, internalReady := startServer(internalAddr, handler, "internal registry", nil, errChan, log)368+ internalSrv, internalReady := startServer(internalAddr, handler, "internal registry", nil, timeouts, errChan, log)
366- return externalSrv, externalReady, internalSrv, internalReady369+ return externalSrv, externalReady, internalSrv, internalReady, nil
367 }370 }
368 371
369 func closedReadyChannel() <-chan struct{} {372 func closedReadyChannel() <-chan struct{} {
@@ -384,18 +387,19 @@ func needsInternalRegistryListener(listenAddr string) bool {
384 // that closes once the listening socket is bound. This lets callers wait for the387 // that closes once the listening socket is bound. This lets callers wait for the
385 // port to be ready before taking actions that depend on it (e.g. auto-start388 // port to be ready before taking actions that depend on it (e.g. auto-start
386 // pulling from the local registry). The returned *http.Server can be used for389 // pulling from the local registry). The returned *http.Server can be used for
387-// graceful shutdown.390+// graceful shutdown. Timeouts are the configured server.http values.
388-func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {391+func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, timeouts domain.RegistryHTTPTimeouts, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
392+ timeouts = timeouts.WithDefaults()
389 ready := make(chan struct{})393 ready := make(chan struct{})
390 394
391 server := &http.Server{395 server := &http.Server{
392 Addr: addr,396 Addr: addr,
393 Handler: handler,397 Handler: handler,
394 Protocols: protocols,398 Protocols: protocols,
395- ReadHeaderTimeout: 10 * time.Second,399+ ReadHeaderTimeout: timeouts.ReadHeaderTimeout,
396- ReadTimeout: 5 * time.Minute,400+ ReadTimeout: timeouts.ReadTimeout,
397- WriteTimeout: 5 * time.Minute,401+ WriteTimeout: timeouts.WriteTimeout,
398- IdleTimeout: 120 * time.Second,402+ IdleTimeout: timeouts.IdleTimeout,
399 MaxHeaderBytes: 1 << 20,403 MaxHeaderBytes: 1 << 20,
400 }404 }
401 405
M internal/app/run.go +25 -14
@@ -195,22 +195,11 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
195195 // The deferred signal.Stop calls ensure signal handlers are properly
196196 // cleaned up before program exit, preventing signal handler leaks.
197197 func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error {
198- // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic
199- // complexity within the project limit of 15.
200- accessWriterConcrete, err := initAccessLog(cfg, log)
198+ accessWriter, closeAccessLog, err := openAccessLog(svc, cfg, log)
201199 if err != nil {
202200 return err
203201 }
204- if accessWriterConcrete != nil {
205- defer accessWriterConcrete.Close()
206- }
207- // Convert to interface only when non-nil to avoid the Go nil-interface pitfall
208- // where a typed nil pointer becomes a non-nil interface value.
209- var accessWriter out.AccessLogWriter
210- if accessWriterConcrete != nil {
211- accessWriter = accessWriterConcrete
212- }
213- accessWriter = withAccessLogExport(svc, accessWriter)
202+ defer closeAccessLog()
214203 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
215204 defer cancel()
216205
@@ -227,7 +216,10 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
227216 svc.httpProxyHandler = httpProxyHandler
228217 svc.httpsProxyHandler = httpsProxyHandler
229218
230- registrySrv, registryReady, internalRegistrySrv, internalRegistryReady := startRegistryServers(cfg, registryHandler, errChan, log)
219+ registrySrv, registryReady, internalRegistrySrv, internalRegistryReady, err := startRegistryServers(cfg, registryHandler, errChan, log)
220+ if err != nil {
221+ return err
222+ }
231223
232224 // closeStarted shuts down any servers that were started before an error occurred,
233225 // preventing leaked listeners during partial startup failures.
@@ -383,3 +375,22 @@ func waitForServerReady(ctx context.Context, ready <-chan struct{}, errChan <-ch
383375 return ctx.Err()
384376 }
385377 }
378+
379+// openAccessLog initializes the access log writer and returns the writer to use
380+// (nil when disabled) with its close function. Kept out of runServers to keep
381+// its cyclomatic complexity within the project limit of 15.
382+func openAccessLog(svc *services, cfg Config, log zerowrap.Logger) (out.AccessLogWriter, func(), error) {
383+ accessWriterConcrete, err := initAccessLog(cfg, log)
384+ if err != nil {
385+ return nil, func() {}, err
386+ }
387+ closeFn := func() {}
388+ // Convert to interface only when non-nil to avoid the Go nil-interface pitfall
389+ // where a typed nil pointer becomes a non-nil interface value.
390+ var accessWriter out.AccessLogWriter
391+ if accessWriterConcrete != nil {
392+ accessWriter = accessWriterConcrete
393+ closeFn = func() { accessWriterConcrete.Close() }
394+ }
395+ return withAccessLogExport(svc, accessWriter), closeFn, nil
396+}
@@ -195,22 +195,11 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
195 // The deferred signal.Stop calls ensure signal handlers are properly195 // The deferred signal.Stop calls ensure signal handlers are properly
196 // cleaned up before program exit, preventing signal handler leaks.196 // cleaned up before program exit, preventing signal handler leaks.
197 func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error {197 func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error {
198- // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic198+ accessWriter, closeAccessLog, err := openAccessLog(svc, cfg, log)
199- // complexity within the project limit of 15.
200- accessWriterConcrete, err := initAccessLog(cfg, log)
201 if err != nil {199 if err != nil {
202 return err200 return err
203 }201 }
204- if accessWriterConcrete != nil {202+ defer closeAccessLog()
205- defer accessWriterConcrete.Close()
206- }
207- // Convert to interface only when non-nil to avoid the Go nil-interface pitfall
208- // where a typed nil pointer becomes a non-nil interface value.
209- var accessWriter out.AccessLogWriter
210- if accessWriterConcrete != nil {
211- accessWriter = accessWriterConcrete
212- }
213- accessWriter = withAccessLogExport(svc, accessWriter)
214 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)203 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
215 defer cancel()204 defer cancel()
216 205
@@ -227,7 +216,10 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
227 svc.httpProxyHandler = httpProxyHandler216 svc.httpProxyHandler = httpProxyHandler
228 svc.httpsProxyHandler = httpsProxyHandler217 svc.httpsProxyHandler = httpsProxyHandler
229 218
230- registrySrv, registryReady, internalRegistrySrv, internalRegistryReady := startRegistryServers(cfg, registryHandler, errChan, log)219+ registrySrv, registryReady, internalRegistrySrv, internalRegistryReady, err := startRegistryServers(cfg, registryHandler, errChan, log)
220+ if err != nil {
221+ return err
222+ }
231 223
232 // closeStarted shuts down any servers that were started before an error occurred,224 // closeStarted shuts down any servers that were started before an error occurred,
233 // preventing leaked listeners during partial startup failures.225 // preventing leaked listeners during partial startup failures.
@@ -383,3 +375,22 @@ func waitForServerReady(ctx context.Context, ready <-chan struct{}, errChan <-ch
383 return ctx.Err()375 return ctx.Err()
384 }376 }
385 }377 }
378+
379+// openAccessLog initializes the access log writer and returns the writer to use
380+// (nil when disabled) with its close function. Kept out of runServers to keep
381+// its cyclomatic complexity within the project limit of 15.
382+func openAccessLog(svc *services, cfg Config, log zerowrap.Logger) (out.AccessLogWriter, func(), error) {
383+ accessWriterConcrete, err := initAccessLog(cfg, log)
384+ if err != nil {
385+ return nil, func() {}, err
386+ }
387+ closeFn := func() {}
388+ // Convert to interface only when non-nil to avoid the Go nil-interface pitfall
389+ // where a typed nil pointer becomes a non-nil interface value.
390+ var accessWriter out.AccessLogWriter
391+ if accessWriterConcrete != nil {
392+ accessWriter = accessWriterConcrete
393+ closeFn = func() { accessWriterConcrete.Close() }
394+ }
395+ return withAccessLogExport(svc, accessWriter), closeFn, nil
396+}
A internal/domain/http_timeouts.go +121 -0
@@ -0,0 +1,121 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "time"
6+)
7+
8+// Default HTTP server timeouts.
9+const (
10+ DefaultRegistryReadHeaderTimeout = 10 * time.Second
11+ DefaultRegistryReadTimeout = 5 * time.Minute
12+ DefaultRegistryWriteTimeout = 5 * time.Minute
13+ DefaultRegistryIdleTimeout = 120 * time.Second
14+
15+ DefaultEntryPointReadHeaderTimeout = 5 * time.Second
16+ DefaultEntryPointIdleTimeout = 30 * time.Second
17+ DefaultEntryPointTransferIdleTimeout = 2 * time.Minute
18+)
19+
20+// RegistryHTTPTimeouts holds the fixed timeouts of the registry HTTP servers
21+// (config section server.http). Read and Write timeouts bound the whole
22+// request and response, which suits registry API calls.
23+type RegistryHTTPTimeouts struct {
24+ ReadHeaderTimeout time.Duration
25+ ReadTimeout time.Duration
26+ WriteTimeout time.Duration
27+ IdleTimeout time.Duration
28+}
29+
30+// DefaultRegistryHTTPTimeouts returns the default registry server timeouts.
31+func DefaultRegistryHTTPTimeouts() RegistryHTTPTimeouts {
32+ return RegistryHTTPTimeouts{
33+ ReadHeaderTimeout: DefaultRegistryReadHeaderTimeout,
34+ ReadTimeout: DefaultRegistryReadTimeout,
35+ WriteTimeout: DefaultRegistryWriteTimeout,
36+ IdleTimeout: DefaultRegistryIdleTimeout,
37+ }
38+}
39+
40+// WithDefaults replaces zero values with the defaults.
41+func (t RegistryHTTPTimeouts) WithDefaults() RegistryHTTPTimeouts {
42+ defaults := DefaultRegistryHTTPTimeouts()
43+ if t.ReadHeaderTimeout == 0 {
44+ t.ReadHeaderTimeout = defaults.ReadHeaderTimeout
45+ }
46+ if t.ReadTimeout == 0 {
47+ t.ReadTimeout = defaults.ReadTimeout
48+ }
49+ if t.WriteTimeout == 0 {
50+ t.WriteTimeout = defaults.WriteTimeout
51+ }
52+ if t.IdleTimeout == 0 {
53+ t.IdleTimeout = defaults.IdleTimeout
54+ }
55+ return t
56+}
57+
58+// Validate rejects negative durations. Zero values mean "use the default".
59+func (t RegistryHTTPTimeouts) Validate() error {
60+ return validateNonNegativeDurations(map[string]time.Duration{
61+ "server.http.read_header_timeout": t.ReadHeaderTimeout,
62+ "server.http.read_timeout": t.ReadTimeout,
63+ "server.http.write_timeout": t.WriteTimeout,
64+ "server.http.idle_timeout": t.IdleTimeout,
65+ })
66+}
67+
68+// HTTPOptions holds the timeouts of the HTTP servers behind traffic
69+// entrypoints (config section traffic.http).
70+//
71+// There is deliberately no fixed read or write timeout: ReadHeaderTimeout
72+// bounds the request header, IdleTimeout bounds keep-alive connections, and
73+// TransferIdleTimeout cuts a transfer only after that long without data in
74+// the direction being waited on.
75+type HTTPOptions struct {
76+ ReadHeaderTimeout time.Duration
77+ IdleTimeout time.Duration
78+ TransferIdleTimeout time.Duration
79+}
80+
81+// DefaultHTTPOptions returns the default entrypoint HTTP timeouts.
82+func DefaultHTTPOptions() HTTPOptions {
83+ return HTTPOptions{
84+ ReadHeaderTimeout: DefaultEntryPointReadHeaderTimeout,
85+ IdleTimeout: DefaultEntryPointIdleTimeout,
86+ TransferIdleTimeout: DefaultEntryPointTransferIdleTimeout,
87+ }
88+}
89+
90+// WithDefaults replaces zero values with the defaults.
91+func (o HTTPOptions) WithDefaults() HTTPOptions {
92+ defaults := DefaultHTTPOptions()
93+ if o.ReadHeaderTimeout == 0 {
94+ o.ReadHeaderTimeout = defaults.ReadHeaderTimeout
95+ }
96+ if o.IdleTimeout == 0 {
97+ o.IdleTimeout = defaults.IdleTimeout
98+ }
99+ if o.TransferIdleTimeout == 0 {
100+ o.TransferIdleTimeout = defaults.TransferIdleTimeout
101+ }
102+ return o
103+}
104+
105+// Validate rejects negative durations. Zero values mean "use the default".
106+func (o HTTPOptions) Validate() error {
107+ return validateNonNegativeDurations(map[string]time.Duration{
108+ "traffic.http.read_header_timeout": o.ReadHeaderTimeout,
109+ "traffic.http.idle_timeout": o.IdleTimeout,
110+ "traffic.http.transfer_idle_timeout": o.TransferIdleTimeout,
111+ })
112+}
113+
114+func validateNonNegativeDurations(values map[string]time.Duration) error {
115+ for name, value := range values {
116+ if value < 0 {
117+ return fmt.Errorf("%s must be positive", name)
118+ }
119+ }
120+ return nil
121+}
@@ -0,0 +1,121 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "time"
6+)
7+
8+// Default HTTP server timeouts.
9+const (
10+ DefaultRegistryReadHeaderTimeout = 10 * time.Second
11+ DefaultRegistryReadTimeout = 5 * time.Minute
12+ DefaultRegistryWriteTimeout = 5 * time.Minute
13+ DefaultRegistryIdleTimeout = 120 * time.Second
14+
15+ DefaultEntryPointReadHeaderTimeout = 5 * time.Second
16+ DefaultEntryPointIdleTimeout = 30 * time.Second
17+ DefaultEntryPointTransferIdleTimeout = 2 * time.Minute
18+)
19+
20+// RegistryHTTPTimeouts holds the fixed timeouts of the registry HTTP servers
21+// (config section server.http). Read and Write timeouts bound the whole
22+// request and response, which suits registry API calls.
23+type RegistryHTTPTimeouts struct {
24+ ReadHeaderTimeout time.Duration
25+ ReadTimeout time.Duration
26+ WriteTimeout time.Duration
27+ IdleTimeout time.Duration
28+}
29+
30+// DefaultRegistryHTTPTimeouts returns the default registry server timeouts.
31+func DefaultRegistryHTTPTimeouts() RegistryHTTPTimeouts {
32+ return RegistryHTTPTimeouts{
33+ ReadHeaderTimeout: DefaultRegistryReadHeaderTimeout,
34+ ReadTimeout: DefaultRegistryReadTimeout,
35+ WriteTimeout: DefaultRegistryWriteTimeout,
36+ IdleTimeout: DefaultRegistryIdleTimeout,
37+ }
38+}
39+
40+// WithDefaults replaces zero values with the defaults.
41+func (t RegistryHTTPTimeouts) WithDefaults() RegistryHTTPTimeouts {
42+ defaults := DefaultRegistryHTTPTimeouts()
43+ if t.ReadHeaderTimeout == 0 {
44+ t.ReadHeaderTimeout = defaults.ReadHeaderTimeout
45+ }
46+ if t.ReadTimeout == 0 {
47+ t.ReadTimeout = defaults.ReadTimeout
48+ }
49+ if t.WriteTimeout == 0 {
50+ t.WriteTimeout = defaults.WriteTimeout
51+ }
52+ if t.IdleTimeout == 0 {
53+ t.IdleTimeout = defaults.IdleTimeout
54+ }
55+ return t
56+}
57+
58+// Validate rejects negative durations. Zero values mean "use the default".
59+func (t RegistryHTTPTimeouts) Validate() error {
60+ return validateNonNegativeDurations(map[string]time.Duration{
61+ "server.http.read_header_timeout": t.ReadHeaderTimeout,
62+ "server.http.read_timeout": t.ReadTimeout,
63+ "server.http.write_timeout": t.WriteTimeout,
64+ "server.http.idle_timeout": t.IdleTimeout,
65+ })
66+}
67+
68+// HTTPOptions holds the timeouts of the HTTP servers behind traffic
69+// entrypoints (config section traffic.http).
70+//
71+// There is deliberately no fixed read or write timeout: ReadHeaderTimeout
72+// bounds the request header, IdleTimeout bounds keep-alive connections, and
73+// TransferIdleTimeout cuts a transfer only after that long without data in
74+// the direction being waited on.
75+type HTTPOptions struct {
76+ ReadHeaderTimeout time.Duration
77+ IdleTimeout time.Duration
78+ TransferIdleTimeout time.Duration
79+}
80+
81+// DefaultHTTPOptions returns the default entrypoint HTTP timeouts.
82+func DefaultHTTPOptions() HTTPOptions {
83+ return HTTPOptions{
84+ ReadHeaderTimeout: DefaultEntryPointReadHeaderTimeout,
85+ IdleTimeout: DefaultEntryPointIdleTimeout,
86+ TransferIdleTimeout: DefaultEntryPointTransferIdleTimeout,
87+ }
88+}
89+
90+// WithDefaults replaces zero values with the defaults.
91+func (o HTTPOptions) WithDefaults() HTTPOptions {
92+ defaults := DefaultHTTPOptions()
93+ if o.ReadHeaderTimeout == 0 {
94+ o.ReadHeaderTimeout = defaults.ReadHeaderTimeout
95+ }
96+ if o.IdleTimeout == 0 {
97+ o.IdleTimeout = defaults.IdleTimeout
98+ }
99+ if o.TransferIdleTimeout == 0 {
100+ o.TransferIdleTimeout = defaults.TransferIdleTimeout
101+ }
102+ return o
103+}
104+
105+// Validate rejects negative durations. Zero values mean "use the default".
106+func (o HTTPOptions) Validate() error {
107+ return validateNonNegativeDurations(map[string]time.Duration{
108+ "traffic.http.read_header_timeout": o.ReadHeaderTimeout,
109+ "traffic.http.idle_timeout": o.IdleTimeout,
110+ "traffic.http.transfer_idle_timeout": o.TransferIdleTimeout,
111+ })
112+}
113+
114+func validateNonNegativeDurations(values map[string]time.Duration) error {
115+ for name, value := range values {
116+ if value < 0 {
117+ return fmt.Errorf("%s must be positive", name)
118+ }
119+ }
120+ return nil
121+}
A internal/domain/http_timeouts_test.go +55 -0
@@ -0,0 +1,55 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestDefaultHTTPOptions(t *testing.T) {
12+ assert.Equal(t, HTTPOptions{
13+ ReadHeaderTimeout: 5 * time.Second,
14+ IdleTimeout: 30 * time.Second,
15+ TransferIdleTimeout: 2 * time.Minute,
16+ }, DefaultHTTPOptions())
17+ assert.NoError(t, DefaultHTTPOptions().Validate())
18+}
19+
20+func TestHTTPOptionsWithDefaults(t *testing.T) {
21+ got := HTTPOptions{TransferIdleTimeout: time.Hour}.WithDefaults()
22+ assert.Equal(t, 5*time.Second, got.ReadHeaderTimeout)
23+ assert.Equal(t, 30*time.Second, got.IdleTimeout)
24+ assert.Equal(t, time.Hour, got.TransferIdleTimeout)
25+}
26+
27+func TestHTTPOptionsValidateRejectsNegative(t *testing.T) {
28+ require.ErrorContains(t, HTTPOptions{ReadHeaderTimeout: -1}.Validate(), "traffic.http.read_header_timeout")
29+ require.ErrorContains(t, HTTPOptions{IdleTimeout: -1}.Validate(), "traffic.http.idle_timeout")
30+ require.ErrorContains(t, HTTPOptions{TransferIdleTimeout: -1}.Validate(), "traffic.http.transfer_idle_timeout")
31+ assert.NoError(t, HTTPOptions{}.Validate(), "zero selects the default")
32+}
33+
34+func TestTrafficGraphRejectsNegativeHTTPOptions(t *testing.T) {
35+ graph := TrafficGraph{Options: TrafficOptions{HTTP: HTTPOptions{TransferIdleTimeout: -time.Second}}}
36+ require.ErrorContains(t, graph.Validate(), "traffic.http.transfer_idle_timeout")
37+}
38+
39+func TestDefaultRegistryHTTPTimeouts(t *testing.T) {
40+ assert.Equal(t, RegistryHTTPTimeouts{
41+ ReadHeaderTimeout: 10 * time.Second,
42+ ReadTimeout: 5 * time.Minute,
43+ WriteTimeout: 5 * time.Minute,
44+ IdleTimeout: 120 * time.Second,
45+ }, DefaultRegistryHTTPTimeouts())
46+ assert.Equal(t, DefaultRegistryHTTPTimeouts(), RegistryHTTPTimeouts{}.WithDefaults())
47+}
48+
49+func TestRegistryHTTPTimeoutsValidate(t *testing.T) {
50+ assert.NoError(t, DefaultRegistryHTTPTimeouts().Validate())
51+ require.ErrorContains(t, RegistryHTTPTimeouts{ReadHeaderTimeout: -1}.Validate(), "server.http.read_header_timeout")
52+ require.ErrorContains(t, RegistryHTTPTimeouts{ReadTimeout: -1}.Validate(), "server.http.read_timeout")
53+ require.ErrorContains(t, RegistryHTTPTimeouts{WriteTimeout: -1}.Validate(), "server.http.write_timeout")
54+ require.ErrorContains(t, RegistryHTTPTimeouts{IdleTimeout: -1}.Validate(), "server.http.idle_timeout")
55+}
@@ -0,0 +1,55 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestDefaultHTTPOptions(t *testing.T) {
12+ assert.Equal(t, HTTPOptions{
13+ ReadHeaderTimeout: 5 * time.Second,
14+ IdleTimeout: 30 * time.Second,
15+ TransferIdleTimeout: 2 * time.Minute,
16+ }, DefaultHTTPOptions())
17+ assert.NoError(t, DefaultHTTPOptions().Validate())
18+}
19+
20+func TestHTTPOptionsWithDefaults(t *testing.T) {
21+ got := HTTPOptions{TransferIdleTimeout: time.Hour}.WithDefaults()
22+ assert.Equal(t, 5*time.Second, got.ReadHeaderTimeout)
23+ assert.Equal(t, 30*time.Second, got.IdleTimeout)
24+ assert.Equal(t, time.Hour, got.TransferIdleTimeout)
25+}
26+
27+func TestHTTPOptionsValidateRejectsNegative(t *testing.T) {
28+ require.ErrorContains(t, HTTPOptions{ReadHeaderTimeout: -1}.Validate(), "traffic.http.read_header_timeout")
29+ require.ErrorContains(t, HTTPOptions{IdleTimeout: -1}.Validate(), "traffic.http.idle_timeout")
30+ require.ErrorContains(t, HTTPOptions{TransferIdleTimeout: -1}.Validate(), "traffic.http.transfer_idle_timeout")
31+ assert.NoError(t, HTTPOptions{}.Validate(), "zero selects the default")
32+}
33+
34+func TestTrafficGraphRejectsNegativeHTTPOptions(t *testing.T) {
35+ graph := TrafficGraph{Options: TrafficOptions{HTTP: HTTPOptions{TransferIdleTimeout: -time.Second}}}
36+ require.ErrorContains(t, graph.Validate(), "traffic.http.transfer_idle_timeout")
37+}
38+
39+func TestDefaultRegistryHTTPTimeouts(t *testing.T) {
40+ assert.Equal(t, RegistryHTTPTimeouts{
41+ ReadHeaderTimeout: 10 * time.Second,
42+ ReadTimeout: 5 * time.Minute,
43+ WriteTimeout: 5 * time.Minute,
44+ IdleTimeout: 120 * time.Second,
45+ }, DefaultRegistryHTTPTimeouts())
46+ assert.Equal(t, DefaultRegistryHTTPTimeouts(), RegistryHTTPTimeouts{}.WithDefaults())
47+}
48+
49+func TestRegistryHTTPTimeoutsValidate(t *testing.T) {
50+ assert.NoError(t, DefaultRegistryHTTPTimeouts().Validate())
51+ require.ErrorContains(t, RegistryHTTPTimeouts{ReadHeaderTimeout: -1}.Validate(), "server.http.read_header_timeout")
52+ require.ErrorContains(t, RegistryHTTPTimeouts{ReadTimeout: -1}.Validate(), "server.http.read_timeout")
53+ require.ErrorContains(t, RegistryHTTPTimeouts{WriteTimeout: -1}.Validate(), "server.http.write_timeout")
54+ require.ErrorContains(t, RegistryHTTPTimeouts{IdleTimeout: -1}.Validate(), "server.http.idle_timeout")
55+}
M internal/domain/traffic.go +6 -2
@@ -42,8 +42,9 @@ type TrafficGraph struct {
4242 }
4343
4444 type TrafficOptions struct {
45- TCP TCPOptions
46- UDP UDPOptions
45+ TCP TCPOptions
46+ UDP UDPOptions
47+ HTTP HTTPOptions
4748 }
4849
4950 type TCPOptions struct {
@@ -230,6 +231,9 @@ func (g TrafficGraph) Validate() error {
230231 }
231232
232233 func validateTrafficOptions(options TrafficOptions) error {
234+ if err := options.HTTP.Validate(); err != nil {
235+ return err
236+ }
233237 if err := validateOptionalPositiveDuration(options.TCP.DialTimeout, "traffic.tcp.dial_timeout"); err != nil {
234238 return err
235239 }
@@ -42,8 +42,9 @@ type TrafficGraph struct {
42 }42 }
43 43
44 type TrafficOptions struct {44 type TrafficOptions struct {
45- TCP TCPOptions45+ TCP TCPOptions
46- UDP UDPOptions46+ UDP UDPOptions
47+ HTTP HTTPOptions
47 }48 }
48 49
49 type TCPOptions struct {50 type TCPOptions struct {
@@ -230,6 +231,9 @@ func (g TrafficGraph) Validate() error {
230 }231 }
231 232
232 func validateTrafficOptions(options TrafficOptions) error {233 func validateTrafficOptions(options TrafficOptions) error {
234+ if err := options.HTTP.Validate(); err != nil {
235+ return err
236+ }
233 if err := validateOptionalPositiveDuration(options.TCP.DialTimeout, "traffic.tcp.dial_timeout"); err != nil {237 if err := validateOptionalPositiveDuration(options.TCP.DialTimeout, "traffic.tcp.dial_timeout"); err != nil {
234 return err238 return err
235 }239 }
M internal/usecase/traffic/builder.go +34 -1
@@ -38,6 +38,15 @@ type Config struct {
3838 TCP TCPConfig `mapstructure:"tcp"`
3939 UDP UDPConfig `mapstructure:"udp"`
4040 TLS TLSConfig `mapstructure:"tls"`
41+ // HTTP configures the HTTP servers behind traffic entrypoints.
42+ HTTP HTTPConfig `mapstructure:"http"`
43+}
44+
45+// HTTPConfig holds traffic.http duration strings (Go duration syntax).
46+type HTTPConfig struct {
47+ ReadHeaderTimeout string `mapstructure:"read_header_timeout"`
48+ IdleTimeout string `mapstructure:"idle_timeout"`
49+ TransferIdleTimeout string `mapstructure:"transfer_idle_timeout"`
4150 }
4251
4352 type TCPConfig struct {
@@ -476,7 +485,31 @@ func buildOptions(cfg Config) (domain.TrafficOptions, error) {
476485 if err != nil {
477486 return domain.TrafficOptions{}, err
478487 }
479- return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil
488+ httpOptions, err := ParseHTTPOptions(cfg.HTTP)
489+ if err != nil {
490+ return domain.TrafficOptions{}, err
491+ }
492+ return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}, HTTP: httpOptions}, nil
493+}
494+
495+// ParseHTTPOptions converts traffic.http duration strings into validated
496+// options. Empty values select the defaults; non-positive or malformed values
497+// are rejected.
498+func ParseHTTPOptions(cfg HTTPConfig) (domain.HTTPOptions, error) {
499+ defaults := domain.DefaultHTTPOptions()
500+ readHeader, err := parsePositiveDurationDefault(cfg.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "traffic.http.read_header_timeout")
501+ if err != nil {
502+ return domain.HTTPOptions{}, err
503+ }
504+ idle, err := parsePositiveDurationDefault(cfg.IdleTimeout, defaults.IdleTimeout, "traffic.http.idle_timeout")
505+ if err != nil {
506+ return domain.HTTPOptions{}, err
507+ }
508+ transferIdle, err := parsePositiveDurationDefault(cfg.TransferIdleTimeout, defaults.TransferIdleTimeout, "traffic.http.transfer_idle_timeout")
509+ if err != nil {
510+ return domain.HTTPOptions{}, err
511+ }
512+ return domain.HTTPOptions{ReadHeaderTimeout: readHeader, IdleTimeout: idle, TransferIdleTimeout: transferIdle}, nil
480513 }
481514
482515 func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {
@@ -38,6 +38,15 @@ type Config struct {
38 TCP TCPConfig `mapstructure:"tcp"`38 TCP TCPConfig `mapstructure:"tcp"`
39 UDP UDPConfig `mapstructure:"udp"`39 UDP UDPConfig `mapstructure:"udp"`
40 TLS TLSConfig `mapstructure:"tls"`40 TLS TLSConfig `mapstructure:"tls"`
41+ // HTTP configures the HTTP servers behind traffic entrypoints.
42+ HTTP HTTPConfig `mapstructure:"http"`
43+}
44+
45+// HTTPConfig holds traffic.http duration strings (Go duration syntax).
46+type HTTPConfig struct {
47+ ReadHeaderTimeout string `mapstructure:"read_header_timeout"`
48+ IdleTimeout string `mapstructure:"idle_timeout"`
49+ TransferIdleTimeout string `mapstructure:"transfer_idle_timeout"`
41 }50 }
42 51
43 type TCPConfig struct {52 type TCPConfig struct {
@@ -476,7 +485,31 @@ func buildOptions(cfg Config) (domain.TrafficOptions, error) {
476 if err != nil {485 if err != nil {
477 return domain.TrafficOptions{}, err486 return domain.TrafficOptions{}, err
478 }487 }
479- return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil488+ httpOptions, err := ParseHTTPOptions(cfg.HTTP)
489+ if err != nil {
490+ return domain.TrafficOptions{}, err
491+ }
492+ return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}, HTTP: httpOptions}, nil
493+}
494+
495+// ParseHTTPOptions converts traffic.http duration strings into validated
496+// options. Empty values select the defaults; non-positive or malformed values
497+// are rejected.
498+func ParseHTTPOptions(cfg HTTPConfig) (domain.HTTPOptions, error) {
499+ defaults := domain.DefaultHTTPOptions()
500+ readHeader, err := parsePositiveDurationDefault(cfg.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "traffic.http.read_header_timeout")
501+ if err != nil {
502+ return domain.HTTPOptions{}, err
503+ }
504+ idle, err := parsePositiveDurationDefault(cfg.IdleTimeout, defaults.IdleTimeout, "traffic.http.idle_timeout")
505+ if err != nil {
506+ return domain.HTTPOptions{}, err
507+ }
508+ transferIdle, err := parsePositiveDurationDefault(cfg.TransferIdleTimeout, defaults.TransferIdleTimeout, "traffic.http.transfer_idle_timeout")
509+ if err != nil {
510+ return domain.HTTPOptions{}, err
511+ }
512+ return domain.HTTPOptions{ReadHeaderTimeout: readHeader, IdleTimeout: idle, TransferIdleTimeout: transferIdle}, nil
480 }513 }
481 514
482 func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {515 func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {
M internal/usecase/traffic/builder_test.go +15 -0
@@ -173,6 +173,21 @@ func TestBuildOptionsParsing(t *testing.T) {
173173 require.ErrorContains(t, err, "traffic.tcp.dial_timeout")
174174 }
175175
176+func TestBuildHTTPOptions(t *testing.T) {
177+ defaults, err := Build(Input{})
178+ require.NoError(t, err)
179+ require.Equal(t, domain.DefaultHTTPOptions(), defaults.Options.HTTP)
180+
181+ graph, err := Build(Input{Traffic: Config{HTTP: HTTPConfig{ReadHeaderTimeout: "1s", IdleTimeout: "2s", TransferIdleTimeout: "3m"}}})
182+ require.NoError(t, err)
183+ require.Equal(t, domain.HTTPOptions{ReadHeaderTimeout: time.Second, IdleTimeout: 2 * time.Second, TransferIdleTimeout: 3 * time.Minute}, graph.Options.HTTP)
184+
185+ for _, value := range []string{"bogus", "-1s", "0s"} {
186+ _, err = Build(Input{Traffic: Config{HTTP: HTTPConfig{TransferIdleTimeout: value}}})
187+ require.ErrorContains(t, err, "traffic.http.transfer_idle_timeout", value)
188+ }
189+}
190+
176191 func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) {
177192 t.Run("duplicate service name", func(t *testing.T) {
178193 _, err := Build(Input{NetworkServices: []NetworkServiceConfig{
@@ -173,6 +173,21 @@ func TestBuildOptionsParsing(t *testing.T) {
173 require.ErrorContains(t, err, "traffic.tcp.dial_timeout")173 require.ErrorContains(t, err, "traffic.tcp.dial_timeout")
174 }174 }
175 175
176+func TestBuildHTTPOptions(t *testing.T) {
177+ defaults, err := Build(Input{})
178+ require.NoError(t, err)
179+ require.Equal(t, domain.DefaultHTTPOptions(), defaults.Options.HTTP)
180+
181+ graph, err := Build(Input{Traffic: Config{HTTP: HTTPConfig{ReadHeaderTimeout: "1s", IdleTimeout: "2s", TransferIdleTimeout: "3m"}}})
182+ require.NoError(t, err)
183+ require.Equal(t, domain.HTTPOptions{ReadHeaderTimeout: time.Second, IdleTimeout: 2 * time.Second, TransferIdleTimeout: 3 * time.Minute}, graph.Options.HTTP)
184+
185+ for _, value := range []string{"bogus", "-1s", "0s"} {
186+ _, err = Build(Input{Traffic: Config{HTTP: HTTPConfig{TransferIdleTimeout: value}}})
187+ require.ErrorContains(t, err, "traffic.http.transfer_idle_timeout", value)
188+ }
189+}
190+
176 func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) {191 func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) {
177 t.Run("duplicate service name", func(t *testing.T) {192 t.Run("duplicate service name", func(t *testing.T) {
178 _, err := Build(Input{NetworkServices: []NetworkServiceConfig{193 _, err := Build(Input{NetworkServices: []NetworkServiceConfig{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close