detached e5ab890

feat(traffic): make HTTP timeouts configurable with an inactivity deadline

Add server.http.* (registry) and traffic.http.* (entrypoints) duration settings with env overrides and load-time validation. Entrypoint HTTP servers drop fixed read/write timeouts in favour of a transfer_idle_timeout middleware that renews deadlines as data flows.
commit
e5ab8907d794190bc9a83c49f2eac0924e590643
author
brice · · 2026-10-16 19:08 UTC
parent
4fc01fa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +1157 -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 +30 -0
@@ -127,6 +127,36 @@ 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+Gordon renews the response write deadline before every write and flush, and the request read deadline before every request body read. The deadline is also armed when a request starts, so the wait for the first response byte from a slow backend is bounded by `transfer_idle_timeout` too. Consequences:
152+
153+- a response that streams data at least once per `transfer_idle_timeout` can last arbitrarily long;
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+- WebSocket and other upgraded connections are not subject to it, and HTTP/2 streams are handled the same way as HTTP/1.
157+
158+`transfer_idle_timeout` does not apply to `[traffic.tcp]` or `[traffic.udp]` routers; those use their own `idle_timeout` (see below).
159+
130160 ## TCP Routers
131161
132162 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,36 @@ 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+Gordon renews the response write deadline before every write and flush, and the request read deadline before every request body read. The deadline is also armed when a request starts, so the wait for the first response byte from a slow backend is bounded by `transfer_idle_timeout` too. Consequences:
152+
153+- a response that streams data at least once per `transfer_idle_timeout` can last arbitrarily long;
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+- WebSocket and other upgraded connections are not subject to it, and HTTP/2 streams are handled the same way as HTTP/1.
157+
158+`transfer_idle_timeout` does not apply to `[traffic.tcp]` or `[traffic.udp]` routers; those use their own `idle_timeout` (see below).
159+
130 ## TCP Routers160 ## TCP Routers
131 161
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`.162 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 +171 -0
@@ -0,0 +1,171 @@
1+package middleware
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "io"
7+ "net"
8+ "net/http"
9+ "sync"
10+ "time"
11+)
12+
13+// TransferIdleTimeout returns a middleware that replaces fixed per-request
14+// read/write timeouts with an inactivity timeout. The server it wraps must not
15+// set ReadTimeout or WriteTimeout.
16+//
17+// - The write deadline is set to now+idle when the request starts and renewed
18+// before every Write and Flush. A response is cut when no byte is written
19+// for idle (the next Write fails because the deadline has expired, or the
20+// pending Write fails when the client stops reading).
21+// - The read deadline is renewed before every request body Read, so a slow
22+// but steady upload is never cut, while a stalled one is.
23+//
24+// Deadlines are applied with http.ResponseController, so HTTP/1.x and HTTP/2
25+// are both supported. Flush, Hijack (protocol upgrades) and Unwrap keep
26+// working. A non-positive idle disables the middleware.
27+func TransferIdleTimeout(idle time.Duration) func(http.Handler) http.Handler {
28+ return func(next http.Handler) http.Handler {
29+ if idle <= 0 {
30+ return next
31+ }
32+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ rc := http.NewResponseController(w)
34+ iw := &idleResponseWriter{ResponseWriter: w, rc: rc, idle: idle}
35+ iw.renew()
36+ if r.Body != nil && r.Body != http.NoBody {
37+ ib := &idleBody{ReadCloser: r.Body, rc: rc, idle: idle, writer: iw}
38+ ib.renew()
39+ r.Body = ib
40+ }
41+ next.ServeHTTP(iw, r)
42+ })
43+ }
44+}
45+
46+// idleResponseWriter renews the write deadline on every Write/Flush.
47+type idleResponseWriter struct {
48+ http.ResponseWriter
49+ rc *http.ResponseController
50+ idle time.Duration
51+
52+ mu sync.Mutex
53+ deadline time.Time
54+}
55+
56+// renew extends the write deadline unless it already expired. An expired
57+// deadline is left in place so that the write fails: the response was silent
58+// for longer than the idle timeout.
59+func (w *idleResponseWriter) renew() {
60+ w.mu.Lock()
61+ defer w.mu.Unlock()
62+ now := time.Now()
63+ if !w.deadline.IsZero() && now.After(w.deadline) {
64+ return
65+ }
66+ next := now.Add(w.idle)
67+ if err := w.rc.SetWriteDeadline(next); err == nil {
68+ w.deadline = next
69+ }
70+}
71+
72+// extend pushes the write deadline forward unconditionally. It is called when
73+// the request body makes progress: a client that is still uploading has not
74+// left the response idle, so the wait for the first response byte starts from
75+// the last upload activity.
76+func (w *idleResponseWriter) extend() {
77+ w.mu.Lock()
78+ defer w.mu.Unlock()
79+ next := time.Now().Add(w.idle)
80+ if err := w.rc.SetWriteDeadline(next); err == nil {
81+ w.deadline = next
82+ }
83+}
84+
85+// maxIdleWriteChunk bounds how much is handed to the connection per deadline
86+// renewal, so one very large Write to a slow but steady client is not cut as if
87+// it were stalled.
88+const maxIdleWriteChunk = 64 * 1024
89+
90+// Write renews the write deadline before each chunk, then writes it.
91+func (w *idleResponseWriter) Write(p []byte) (int, error) {
92+ if len(p) == 0 {
93+ w.renew()
94+ return w.ResponseWriter.Write(p)
95+ }
96+ written := 0
97+ for written < len(p) {
98+ chunk := p[written:min(written+maxIdleWriteChunk, len(p))]
99+ w.renew()
100+ n, err := w.ResponseWriter.Write(chunk)
101+ written += n
102+ if err != nil {
103+ return written, err
104+ }
105+ }
106+ return written, nil
107+}
108+
109+// FlushError renews the write deadline, then flushes. It lets
110+// http.ResponseController report flush failures.
111+func (w *idleResponseWriter) FlushError() error {
112+ w.renew()
113+ return w.rc.Flush()
114+}
115+
116+// Flush implements http.Flusher.
117+func (w *idleResponseWriter) Flush() {
118+ _ = w.FlushError()
119+}
120+
121+// Hijack implements http.Hijacker. The server clears connection deadlines when
122+// a connection is hijacked, so upgraded streams (e.g. WebSocket) are not
123+// subject to the inactivity timeout.
124+func (w *idleResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
125+ return w.rc.Hijack()
126+}
127+
128+// Unwrap exposes the underlying writer to http.ResponseController.
129+func (w *idleResponseWriter) Unwrap() http.ResponseWriter {
130+ return w.ResponseWriter
131+}
132+
133+// idleBody renews the read deadline before every request body Read.
134+type idleBody struct {
135+ io.ReadCloser
136+ rc *http.ResponseController
137+ idle time.Duration
138+ writer *idleResponseWriter
139+}
140+
141+func (b *idleBody) renew() {
142+ _ = b.rc.SetReadDeadline(time.Now().Add(b.idle))
143+}
144+
145+// clear removes the read deadline. It must not stay armed once the body is
146+// fully read: net/http then watches the connection for client disconnects and
147+// treats a read timeout as an abort.
148+func (b *idleBody) clear() {
149+ _ = b.rc.SetReadDeadline(time.Time{})
150+}
151+
152+// Read renews the read deadline, reads, and clears the deadline at EOF.
153+func (b *idleBody) Read(p []byte) (int, error) {
154+ b.renew()
155+ n, err := b.ReadCloser.Read(p)
156+ if n > 0 || errors.Is(err, io.EOF) {
157+ b.writer.extend()
158+ }
159+ if errors.Is(err, io.EOF) {
160+ b.clear()
161+ }
162+ return n, err
163+}
164+
165+// Close drains within a fresh deadline, then clears it.
166+func (b *idleBody) Close() error {
167+ b.renew()
168+ err := b.ReadCloser.Close()
169+ b.clear()
170+ return err
171+}
@@ -0,0 +1,171 @@
1+package middleware
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "io"
7+ "net"
8+ "net/http"
9+ "sync"
10+ "time"
11+)
12+
13+// TransferIdleTimeout returns a middleware that replaces fixed per-request
14+// read/write timeouts with an inactivity timeout. The server it wraps must not
15+// set ReadTimeout or WriteTimeout.
16+//
17+// - The write deadline is set to now+idle when the request starts and renewed
18+// before every Write and Flush. A response is cut when no byte is written
19+// for idle (the next Write fails because the deadline has expired, or the
20+// pending Write fails when the client stops reading).
21+// - The read deadline is renewed before every request body Read, so a slow
22+// but steady upload is never cut, while a stalled one is.
23+//
24+// Deadlines are applied with http.ResponseController, so HTTP/1.x and HTTP/2
25+// are both supported. Flush, Hijack (protocol upgrades) and Unwrap keep
26+// working. A non-positive idle disables the middleware.
27+func TransferIdleTimeout(idle time.Duration) func(http.Handler) http.Handler {
28+ return func(next http.Handler) http.Handler {
29+ if idle <= 0 {
30+ return next
31+ }
32+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ rc := http.NewResponseController(w)
34+ iw := &idleResponseWriter{ResponseWriter: w, rc: rc, idle: idle}
35+ iw.renew()
36+ if r.Body != nil && r.Body != http.NoBody {
37+ ib := &idleBody{ReadCloser: r.Body, rc: rc, idle: idle, writer: iw}
38+ ib.renew()
39+ r.Body = ib
40+ }
41+ next.ServeHTTP(iw, r)
42+ })
43+ }
44+}
45+
46+// idleResponseWriter renews the write deadline on every Write/Flush.
47+type idleResponseWriter struct {
48+ http.ResponseWriter
49+ rc *http.ResponseController
50+ idle time.Duration
51+
52+ mu sync.Mutex
53+ deadline time.Time
54+}
55+
56+// renew extends the write deadline unless it already expired. An expired
57+// deadline is left in place so that the write fails: the response was silent
58+// for longer than the idle timeout.
59+func (w *idleResponseWriter) renew() {
60+ w.mu.Lock()
61+ defer w.mu.Unlock()
62+ now := time.Now()
63+ if !w.deadline.IsZero() && now.After(w.deadline) {
64+ return
65+ }
66+ next := now.Add(w.idle)
67+ if err := w.rc.SetWriteDeadline(next); err == nil {
68+ w.deadline = next
69+ }
70+}
71+
72+// extend pushes the write deadline forward unconditionally. It is called when
73+// the request body makes progress: a client that is still uploading has not
74+// left the response idle, so the wait for the first response byte starts from
75+// the last upload activity.
76+func (w *idleResponseWriter) extend() {
77+ w.mu.Lock()
78+ defer w.mu.Unlock()
79+ next := time.Now().Add(w.idle)
80+ if err := w.rc.SetWriteDeadline(next); err == nil {
81+ w.deadline = next
82+ }
83+}
84+
85+// maxIdleWriteChunk bounds how much is handed to the connection per deadline
86+// renewal, so one very large Write to a slow but steady client is not cut as if
87+// it were stalled.
88+const maxIdleWriteChunk = 64 * 1024
89+
90+// Write renews the write deadline before each chunk, then writes it.
91+func (w *idleResponseWriter) Write(p []byte) (int, error) {
92+ if len(p) == 0 {
93+ w.renew()
94+ return w.ResponseWriter.Write(p)
95+ }
96+ written := 0
97+ for written < len(p) {
98+ chunk := p[written:min(written+maxIdleWriteChunk, len(p))]
99+ w.renew()
100+ n, err := w.ResponseWriter.Write(chunk)
101+ written += n
102+ if err != nil {
103+ return written, err
104+ }
105+ }
106+ return written, nil
107+}
108+
109+// FlushError renews the write deadline, then flushes. It lets
110+// http.ResponseController report flush failures.
111+func (w *idleResponseWriter) FlushError() error {
112+ w.renew()
113+ return w.rc.Flush()
114+}
115+
116+// Flush implements http.Flusher.
117+func (w *idleResponseWriter) Flush() {
118+ _ = w.FlushError()
119+}
120+
121+// Hijack implements http.Hijacker. The server clears connection deadlines when
122+// a connection is hijacked, so upgraded streams (e.g. WebSocket) are not
123+// subject to the inactivity timeout.
124+func (w *idleResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
125+ return w.rc.Hijack()
126+}
127+
128+// Unwrap exposes the underlying writer to http.ResponseController.
129+func (w *idleResponseWriter) Unwrap() http.ResponseWriter {
130+ return w.ResponseWriter
131+}
132+
133+// idleBody renews the read deadline before every request body Read.
134+type idleBody struct {
135+ io.ReadCloser
136+ rc *http.ResponseController
137+ idle time.Duration
138+ writer *idleResponseWriter
139+}
140+
141+func (b *idleBody) renew() {
142+ _ = b.rc.SetReadDeadline(time.Now().Add(b.idle))
143+}
144+
145+// clear removes the read deadline. It must not stay armed once the body is
146+// fully read: net/http then watches the connection for client disconnects and
147+// treats a read timeout as an abort.
148+func (b *idleBody) clear() {
149+ _ = b.rc.SetReadDeadline(time.Time{})
150+}
151+
152+// Read renews the read deadline, reads, and clears the deadline at EOF.
153+func (b *idleBody) Read(p []byte) (int, error) {
154+ b.renew()
155+ n, err := b.ReadCloser.Read(p)
156+ if n > 0 || errors.Is(err, io.EOF) {
157+ b.writer.extend()
158+ }
159+ if errors.Is(err, io.EOF) {
160+ b.clear()
161+ }
162+ return n, err
163+}
164+
165+// Close drains within a fresh deadline, then clears it.
166+func (b *idleBody) Close() error {
167+ b.renew()
168+ err := b.ReadCloser.Close()
169+ b.clear()
170+ return err
171+}
A internal/adapters/in/http/middleware/transfer_idle_test.go +229 -0
@@ -0,0 +1,229 @@
1+package middleware
2+
3+import (
4+ "bufio"
5+ "io"
6+ "net"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+const testIdle = 300 * time.Millisecond
19+
20+func newIdleServer(t *testing.T, handler http.Handler) *httptest.Server {
21+ t.Helper()
22+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(handler))
23+ srv.Config.ReadHeaderTimeout = 5 * time.Second
24+ srv.Start()
25+ t.Cleanup(srv.Close)
26+ return srv
27+}
28+
29+func TestTransferIdleTimeout_SlowActiveDownloadCompletes(t *testing.T) {
30+ const chunks = 12 // 12 x 50ms = 600ms, twice the idle timeout
31+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
32+ for range chunks {
33+ _, _ = w.Write([]byte("x"))
34+ w.(http.Flusher).Flush()
35+ time.Sleep(50 * time.Millisecond)
36+ }
37+ }))
38+
39+ start := time.Now()
40+ resp, err := http.Get(srv.URL)
41+ require.NoError(t, err)
42+ defer resp.Body.Close()
43+ body, err := io.ReadAll(resp.Body)
44+ require.NoError(t, err)
45+ assert.Len(t, body, chunks)
46+ assert.Greater(t, time.Since(start), testIdle, "transfer must outlast the idle timeout")
47+}
48+
49+func TestTransferIdleTimeout_SilentResponseIsCut(t *testing.T) {
50+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
51+ _, _ = w.Write([]byte("start"))
52+ w.(http.Flusher).Flush()
53+ time.Sleep(3 * testIdle)
54+ _, _ = w.Write([]byte("late"))
55+ w.(http.Flusher).Flush()
56+ }))
57+
58+ resp, err := http.Get(srv.URL)
59+ require.NoError(t, err)
60+ defer resp.Body.Close()
61+ body, err := io.ReadAll(resp.Body)
62+ require.Error(t, err, "connection must be cut after the idle timeout")
63+ assert.Equal(t, "start", string(body))
64+}
65+
66+func TestTransferIdleTimeout_SilentHandlerBeforeFirstByteIsCut(t *testing.T) {
67+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
68+ time.Sleep(3 * testIdle)
69+ _, _ = w.Write([]byte("late"))
70+ }))
71+
72+ resp, err := http.Get(srv.URL)
73+ if err == nil {
74+ defer resp.Body.Close()
75+ _, err = io.ReadAll(resp.Body)
76+ }
77+ require.Error(t, err)
78+}
79+
80+func TestTransferIdleTimeout_SlowActiveUploadCompletes(t *testing.T) {
81+ const chunks = 10 // 10 x 60ms = 600ms
82+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83+ n, err := io.Copy(io.Discard, r.Body)
84+ if err != nil {
85+ http.Error(w, err.Error(), http.StatusBadRequest)
86+ return
87+ }
88+ _, _ = w.Write([]byte(strings.Repeat("o", int(n))))
89+ }))
90+
91+ pr, pw := io.Pipe()
92+ go func() {
93+ for range chunks {
94+ _, _ = pw.Write([]byte("u"))
95+ time.Sleep(60 * time.Millisecond)
96+ }
97+ _ = pw.Close()
98+ }()
99+ resp, err := http.Post(srv.URL, "application/octet-stream", pr)
100+ require.NoError(t, err)
101+ defer resp.Body.Close()
102+ body, err := io.ReadAll(resp.Body)
103+ require.NoError(t, err)
104+ assert.Equal(t, http.StatusOK, resp.StatusCode)
105+ assert.Len(t, body, chunks)
106+}
107+
108+func TestTransferIdleTimeout_StalledUploadIsCut(t *testing.T) {
109+ var readErr atomic.Value
110+ done := make(chan struct{})
111+ srv := newIdleServer(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
112+ defer close(done)
113+ _, err := io.Copy(io.Discard, r.Body)
114+ if err != nil {
115+ readErr.Store(err)
116+ }
117+ }))
118+
119+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
120+ require.NoError(t, err)
121+ defer conn.Close()
122+ _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nabc"))
123+ require.NoError(t, err)
124+
125+ select {
126+ case <-done:
127+ case <-time.After(5 * testIdle):
128+ t.Fatal("stalled upload was not cut")
129+ }
130+ assert.NotNil(t, readErr.Load(), "body read must fail after the idle timeout")
131+}
132+
133+func TestTransferIdleTimeout_PreservesFlusherAndUnwrap(t *testing.T) {
134+ var isFlusher, unwrapped bool
135+ h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
136+ _, isFlusher = w.(http.Flusher)
137+ _, unwrapped = w.(interface{ Unwrap() http.ResponseWriter })
138+ }))
139+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
140+ assert.True(t, isFlusher)
141+ assert.True(t, unwrapped)
142+}
143+
144+func TestTransferIdleTimeout_HijackStillWorks(t *testing.T) {
145+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
146+ hj, ok := w.(http.Hijacker)
147+ if !ok {
148+ http.Error(w, "no hijacker", http.StatusInternalServerError)
149+ return
150+ }
151+ conn, rw, err := hj.Hijack()
152+ if err != nil {
153+ return
154+ }
155+ defer conn.Close()
156+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n")
157+ _ = rw.Flush()
158+ // Stay open longer than the idle timeout: hijacked streams are exempt.
159+ time.Sleep(2 * testIdle)
160+ _, _ = rw.WriteString("hello")
161+ _ = rw.Flush()
162+ }))
163+
164+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
165+ require.NoError(t, err)
166+ defer conn.Close()
167+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n"))
168+ require.NoError(t, err)
169+
170+ br := bufio.NewReader(conn)
171+ resp, err := http.ReadResponse(br, nil)
172+ require.NoError(t, err)
173+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
174+ buf := make([]byte, 5)
175+ _, err = io.ReadFull(br, buf)
176+ require.NoError(t, err)
177+ assert.Equal(t, "hello", string(buf))
178+}
179+
180+func TestTransferIdleTimeout_ReverseProxyFlushesStream(t *testing.T) {
181+ // Flush propagation through a wrapped writer, as used by httputil.ReverseProxy.
182+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
183+ rc := http.NewResponseController(w)
184+ for range 8 {
185+ _, _ = w.Write([]byte("."))
186+ if err := rc.Flush(); err != nil {
187+ return
188+ }
189+ time.Sleep(50 * time.Millisecond)
190+ }
191+ }))
192+ resp, err := http.Get(srv.URL)
193+ require.NoError(t, err)
194+ defer resp.Body.Close()
195+ body, err := io.ReadAll(resp.Body)
196+ require.NoError(t, err)
197+ assert.Len(t, body, 8)
198+}
199+
200+func TestTransferIdleTimeout_DisabledWhenNonPositive(t *testing.T) {
201+ called := false
202+ inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
203+ h := TransferIdleTimeout(0)(inner)
204+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
205+ assert.True(t, called)
206+}
207+
208+func TestTransferIdleTimeout_HTTP2(t *testing.T) {
209+ const chunks = 12
210+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
211+ for range chunks {
212+ _, _ = w.Write([]byte("x"))
213+ w.(http.Flusher).Flush()
214+ time.Sleep(50 * time.Millisecond)
215+ }
216+ })))
217+ srv.EnableHTTP2 = true
218+ srv.StartTLS()
219+ defer srv.Close()
220+
221+ client := srv.Client()
222+ resp, err := client.Get(srv.URL)
223+ require.NoError(t, err)
224+ defer resp.Body.Close()
225+ assert.Equal(t, 2, resp.ProtoMajor)
226+ body, err := io.ReadAll(resp.Body)
227+ require.NoError(t, err)
228+ assert.Len(t, body, chunks)
229+}
@@ -0,0 +1,229 @@
1+package middleware
2+
3+import (
4+ "bufio"
5+ "io"
6+ "net"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+const testIdle = 300 * time.Millisecond
19+
20+func newIdleServer(t *testing.T, handler http.Handler) *httptest.Server {
21+ t.Helper()
22+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(handler))
23+ srv.Config.ReadHeaderTimeout = 5 * time.Second
24+ srv.Start()
25+ t.Cleanup(srv.Close)
26+ return srv
27+}
28+
29+func TestTransferIdleTimeout_SlowActiveDownloadCompletes(t *testing.T) {
30+ const chunks = 12 // 12 x 50ms = 600ms, twice the idle timeout
31+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
32+ for range chunks {
33+ _, _ = w.Write([]byte("x"))
34+ w.(http.Flusher).Flush()
35+ time.Sleep(50 * time.Millisecond)
36+ }
37+ }))
38+
39+ start := time.Now()
40+ resp, err := http.Get(srv.URL)
41+ require.NoError(t, err)
42+ defer resp.Body.Close()
43+ body, err := io.ReadAll(resp.Body)
44+ require.NoError(t, err)
45+ assert.Len(t, body, chunks)
46+ assert.Greater(t, time.Since(start), testIdle, "transfer must outlast the idle timeout")
47+}
48+
49+func TestTransferIdleTimeout_SilentResponseIsCut(t *testing.T) {
50+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
51+ _, _ = w.Write([]byte("start"))
52+ w.(http.Flusher).Flush()
53+ time.Sleep(3 * testIdle)
54+ _, _ = w.Write([]byte("late"))
55+ w.(http.Flusher).Flush()
56+ }))
57+
58+ resp, err := http.Get(srv.URL)
59+ require.NoError(t, err)
60+ defer resp.Body.Close()
61+ body, err := io.ReadAll(resp.Body)
62+ require.Error(t, err, "connection must be cut after the idle timeout")
63+ assert.Equal(t, "start", string(body))
64+}
65+
66+func TestTransferIdleTimeout_SilentHandlerBeforeFirstByteIsCut(t *testing.T) {
67+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
68+ time.Sleep(3 * testIdle)
69+ _, _ = w.Write([]byte("late"))
70+ }))
71+
72+ resp, err := http.Get(srv.URL)
73+ if err == nil {
74+ defer resp.Body.Close()
75+ _, err = io.ReadAll(resp.Body)
76+ }
77+ require.Error(t, err)
78+}
79+
80+func TestTransferIdleTimeout_SlowActiveUploadCompletes(t *testing.T) {
81+ const chunks = 10 // 10 x 60ms = 600ms
82+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83+ n, err := io.Copy(io.Discard, r.Body)
84+ if err != nil {
85+ http.Error(w, err.Error(), http.StatusBadRequest)
86+ return
87+ }
88+ _, _ = w.Write([]byte(strings.Repeat("o", int(n))))
89+ }))
90+
91+ pr, pw := io.Pipe()
92+ go func() {
93+ for range chunks {
94+ _, _ = pw.Write([]byte("u"))
95+ time.Sleep(60 * time.Millisecond)
96+ }
97+ _ = pw.Close()
98+ }()
99+ resp, err := http.Post(srv.URL, "application/octet-stream", pr)
100+ require.NoError(t, err)
101+ defer resp.Body.Close()
102+ body, err := io.ReadAll(resp.Body)
103+ require.NoError(t, err)
104+ assert.Equal(t, http.StatusOK, resp.StatusCode)
105+ assert.Len(t, body, chunks)
106+}
107+
108+func TestTransferIdleTimeout_StalledUploadIsCut(t *testing.T) {
109+ var readErr atomic.Value
110+ done := make(chan struct{})
111+ srv := newIdleServer(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
112+ defer close(done)
113+ _, err := io.Copy(io.Discard, r.Body)
114+ if err != nil {
115+ readErr.Store(err)
116+ }
117+ }))
118+
119+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
120+ require.NoError(t, err)
121+ defer conn.Close()
122+ _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nabc"))
123+ require.NoError(t, err)
124+
125+ select {
126+ case <-done:
127+ case <-time.After(5 * testIdle):
128+ t.Fatal("stalled upload was not cut")
129+ }
130+ assert.NotNil(t, readErr.Load(), "body read must fail after the idle timeout")
131+}
132+
133+func TestTransferIdleTimeout_PreservesFlusherAndUnwrap(t *testing.T) {
134+ var isFlusher, unwrapped bool
135+ h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
136+ _, isFlusher = w.(http.Flusher)
137+ _, unwrapped = w.(interface{ Unwrap() http.ResponseWriter })
138+ }))
139+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
140+ assert.True(t, isFlusher)
141+ assert.True(t, unwrapped)
142+}
143+
144+func TestTransferIdleTimeout_HijackStillWorks(t *testing.T) {
145+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
146+ hj, ok := w.(http.Hijacker)
147+ if !ok {
148+ http.Error(w, "no hijacker", http.StatusInternalServerError)
149+ return
150+ }
151+ conn, rw, err := hj.Hijack()
152+ if err != nil {
153+ return
154+ }
155+ defer conn.Close()
156+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n")
157+ _ = rw.Flush()
158+ // Stay open longer than the idle timeout: hijacked streams are exempt.
159+ time.Sleep(2 * testIdle)
160+ _, _ = rw.WriteString("hello")
161+ _ = rw.Flush()
162+ }))
163+
164+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
165+ require.NoError(t, err)
166+ defer conn.Close()
167+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n"))
168+ require.NoError(t, err)
169+
170+ br := bufio.NewReader(conn)
171+ resp, err := http.ReadResponse(br, nil)
172+ require.NoError(t, err)
173+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
174+ buf := make([]byte, 5)
175+ _, err = io.ReadFull(br, buf)
176+ require.NoError(t, err)
177+ assert.Equal(t, "hello", string(buf))
178+}
179+
180+func TestTransferIdleTimeout_ReverseProxyFlushesStream(t *testing.T) {
181+ // Flush propagation through a wrapped writer, as used by httputil.ReverseProxy.
182+ srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
183+ rc := http.NewResponseController(w)
184+ for range 8 {
185+ _, _ = w.Write([]byte("."))
186+ if err := rc.Flush(); err != nil {
187+ return
188+ }
189+ time.Sleep(50 * time.Millisecond)
190+ }
191+ }))
192+ resp, err := http.Get(srv.URL)
193+ require.NoError(t, err)
194+ defer resp.Body.Close()
195+ body, err := io.ReadAll(resp.Body)
196+ require.NoError(t, err)
197+ assert.Len(t, body, 8)
198+}
199+
200+func TestTransferIdleTimeout_DisabledWhenNonPositive(t *testing.T) {
201+ called := false
202+ inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
203+ h := TransferIdleTimeout(0)(inner)
204+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
205+ assert.True(t, called)
206+}
207+
208+func TestTransferIdleTimeout_HTTP2(t *testing.T) {
209+ const chunks = 12
210+ srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
211+ for range chunks {
212+ _, _ = w.Write([]byte("x"))
213+ w.(http.Flusher).Flush()
214+ time.Sleep(50 * time.Millisecond)
215+ }
216+ })))
217+ srv.EnableHTTP2 = true
218+ srv.StartTLS()
219+ defer srv.Close()
220+
221+ client := srv.Client()
222+ resp, err := client.Get(srv.URL)
223+ require.NoError(t, err)
224+ defer resp.Body.Close()
225+ assert.Equal(t, 2, resp.ProtoMajor)
226+ body, err := io.ReadAll(resp.Body)
227+ require.NoError(t, err)
228+ assert.Len(t, body, chunks)
229+}
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 +118 -0
@@ -0,0 +1,118 @@
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+ _, _ = w.Write([]byte("x"))
88+ w.(http.Flusher).Flush()
89+ if silent {
90+ time.Sleep(3 * idle)
91+ } else {
92+ time.Sleep(50 * time.Millisecond)
93+ }
94+ }
95+ }), nil)
96+ require.NoError(t, manager.Apply(context.Background(), &graph))
97+ defer shutdownManager(t, manager)
98+
99+ get := func(path string) (int, error) {
100+ conn := dialTCP(t, graph.EntryPoints[0].Address)
101+ defer conn.Close()
102+ _, err := conn.Write([]byte("GET " + path + " HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
103+ require.NoError(t, err)
104+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
105+ require.NoError(t, err)
106+ defer resp.Body.Close()
107+ body, err := io.ReadAll(resp.Body)
108+ return len(body), err
109+ }
110+
111+ n, err := get("/active")
112+ require.NoError(t, err)
113+ assert.Equal(t, 12, n, "active transfer longer than the idle timeout must complete")
114+
115+ n, err = get("/silent")
116+ require.Error(t, err, "silent transfer must be cut")
117+ assert.Less(t, n, 12)
118+}
@@ -0,0 +1,118 @@
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+ _, _ = w.Write([]byte("x"))
88+ w.(http.Flusher).Flush()
89+ if silent {
90+ time.Sleep(3 * idle)
91+ } else {
92+ time.Sleep(50 * time.Millisecond)
93+ }
94+ }
95+ }), nil)
96+ require.NoError(t, manager.Apply(context.Background(), &graph))
97+ defer shutdownManager(t, manager)
98+
99+ get := func(path string) (int, error) {
100+ conn := dialTCP(t, graph.EntryPoints[0].Address)
101+ defer conn.Close()
102+ _, err := conn.Write([]byte("GET " + path + " HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
103+ require.NoError(t, err)
104+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
105+ require.NoError(t, err)
106+ defer resp.Body.Close()
107+ body, err := io.ReadAll(resp.Body)
108+ return len(body), err
109+ }
110+
111+ n, err := get("/active")
112+ require.NoError(t, err)
113+ assert.Equal(t, 12, n, "active transfer longer than the idle timeout must complete")
114+
115+ n, err = get("/silent")
116+ require.Error(t, err, "silent transfer must be cut")
117+ assert.Less(t, n, 12)
118+}
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