Merge pull request 'feat(traffic): configurable HTTP timeouts with an inactivity deadline' (#20) from feat/configurable-http-timeouts into main
19 files · +1685 -39
M docs/config/index.md +9 -0
| @@ -156,6 +156,13 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G | |||
| 156 | 156 | | `server.data_dir` | `~/.gordon` | | |
| 157 | 157 | | `server.max_blob_chunk_size` | `"95MB"` | | |
| 158 | 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 | 166 | | `auth.enabled` | `true` | | |
| 160 | 167 | | `auth.secrets_backend` | `"unsafe"` | | |
| 161 | 168 | | `auth.token_expiry` | `"30d"` | | |
| @@ -210,6 +217,7 @@ gordon daemon reload | |||
| 210 | 217 | | `server.max_proxy_body_size` | | |
| 211 | 218 | | `server.max_proxy_response_size` | | |
| 212 | 219 | | `server.max_concurrent_conns` | | |
| 220 | + | | `traffic.http.*` (new connections after the traffic graph is applied) | | |
| 213 | 221 | ||
| 214 | 222 | ||
| 215 | 223 | ### Requires restart | |
| @@ -220,6 +228,7 @@ gordon daemon reload | |||
| 220 | 228 | | `server.data_dir` | | |
| 221 | 229 | | `server.max_blob_chunk_size` | | |
| 222 | 230 | | `server.max_blob_size` | | |
| 231 | + | | `server.http.*` | | |
| 223 | 232 | | `auth.*` | | |
| 224 | 233 | ||
| 225 | 234 | ## 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 restart | 223 | ### 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 Override | 234 | ## Environment Variable Override | ||
M docs/config/reference.md +18 -0
| @@ -21,6 +21,12 @@ registry_allowed_ips = [] # IPs or CIDR ranges allowed to acc | |||
| 21 | 21 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach HTTP proxy paths (empty = allow all, e.g. Cloudflare IPs) | |
| 22 | 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 | 31 | # ENTRYPOINTS | |
| 26 | 32 | # ============================================================================= | |
| @@ -32,6 +38,11 @@ trusted_cidrs = [] # Peer socket IP allowlist for all | |||
| 32 | 38 | # raw_fallback_trusted_cidrs = ["100.64.0.0/10"] | |
| 33 | 39 | # 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 | 47 | # DNS | |
| 37 | 48 | # ============================================================================= | |
| @@ -241,6 +252,13 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 241 | 252 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 242 | 253 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 243 | 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 | 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 | 263 | | `entrypoints.<name>.protocol` | none | Entrypoint protocol: `smart_tcp`, `tls_mux`, `tcp`, or `udp` | | |
| 246 | 264 | | `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 | # ENTRYPOINTS | 31 | # 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 = false | 39 | # 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 | # DNS | 47 | # 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" | |||
| 50 | 50 | ||
| 51 | 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 | 74 | ## Internal CA and TLS | |
| 54 | 75 | ||
| 55 | 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`. | |
| @@ -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 TLS | 74 | ## Internal CA and TLS | ||
| 54 | 75 | ||||
| 55 | Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`. | 76 | Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`. | ||
M docs/config/traffic.md +31 -0
| @@ -127,6 +127,37 @@ allow_public_raw_fallback = true | |||
| 127 | 127 | ||
| 128 | 128 | `trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`. | |
| 129 | 129 | ||
| 130 | + | ## HTTP Timeouts | |
| 131 | + | ||
| 132 | + | The HTTP servers behind `smart_tcp` and `tls_mux` entrypoints serve apps and the admin API. They do not use a fixed read or write timeout, so a download or upload that keeps moving data is never cut for taking too long. A connection is cut only after `transfer_idle_timeout` passes with no data in the direction being waited on. | |
| 133 | + | ||
| 134 | + | ```toml | |
| 135 | + | [traffic.http] | |
| 136 | + | read_header_timeout = "5s" | |
| 137 | + | idle_timeout = "30s" | |
| 138 | + | transfer_idle_timeout = "2m" | |
| 139 | + | ``` | |
| 140 | + | ||
| 141 | + | | Option | Env | Default | Description | | |
| 142 | + | |--------|-----|---------|-------------| | |
| 143 | + | | `traffic.http.read_header_timeout` | `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT` | `"5s"` | Maximum time to read request headers | | |
| 144 | + | | `traffic.http.idle_timeout` | `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT` | `"30s"` | Maximum keep-alive idle time between requests | | |
| 145 | + | | `traffic.http.transfer_idle_timeout` | `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT` | `"2m"` | Inactivity timeout for request bodies and responses | | |
| 146 | + | ||
| 147 | + | Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads. Servers pick up changes when the traffic graph is next applied (reload); connections already in flight keep their previous timeout. | |
| 148 | + | ||
| 149 | + | ### Inactivity timeout | |
| 150 | + | ||
| 151 | + | Each request has an inactivity timer, armed when the request starts. Progress resets it: a response chunk accepted by the client connection (8 KiB granularity), a flush, or request body bytes read. If `transfer_idle_timeout` passes without progress, Gordon cancels the request context, which aborts the upstream request of a reverse-proxied app, and closes the client connection. When the handler returns, the final response flush and the drain of an unread request body are bounded by the same duration. Consequences: | |
| 152 | + | ||
| 153 | + | - a response that streams data at least once per `transfer_idle_timeout` can last arbitrarily long, and a client that keeps reading is never cut (it must accept 8 KiB per `transfer_idle_timeout`, about 68 B/s at the 2 minute default); | |
| 154 | + | - an upload whose body arrives at least once per `transfer_idle_timeout` can last arbitrarily long; | |
| 155 | + | - a backend that stays silent longer than `transfer_idle_timeout` before or between response chunks has its connection cut, so set it above the slowest expected backend response time; | |
| 156 | + | - the timer also fires when a handler or backend is silent and no write is pending; | |
| 157 | + | - WebSocket and other upgraded connections are not subject to it, and HTTP/2 streams are handled the same way as HTTP/1. | |
| 158 | + | ||
| 159 | + | `transfer_idle_timeout` does not apply to `[traffic.tcp]` or `[traffic.udp]` routers; those use their own `idle_timeout` (see below). | |
| 160 | + | ||
| 130 | 161 | ## TCP Routers | |
| 131 | 162 | ||
| 132 | 163 | Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`. | |
| @@ -127,6 +127,37 @@ allow_public_raw_fallback = true | |||||
| 127 | 127 | ||||
| 128 | `trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`. | 128 | `trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`. | ||
| 129 | 129 | ||||
| 130 | + | ## HTTP Timeouts | |||
| 131 | + | ||||
| 132 | + | The HTTP servers behind `smart_tcp` and `tls_mux` entrypoints serve apps and the admin API. They do not use a fixed read or write timeout, so a download or upload that keeps moving data is never cut for taking too long. A connection is cut only after `transfer_idle_timeout` passes with no data in the direction being waited on. | |||
| 133 | + | ||||
| 134 | + | ```toml | |||
| 135 | + | [traffic.http] | |||
| 136 | + | read_header_timeout = "5s" | |||
| 137 | + | idle_timeout = "30s" | |||
| 138 | + | transfer_idle_timeout = "2m" | |||
| 139 | + | ``` | |||
| 140 | + | ||||
| 141 | + | | Option | Env | Default | Description | | |||
| 142 | + | |--------|-----|---------|-------------| | |||
| 143 | + | | `traffic.http.read_header_timeout` | `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT` | `"5s"` | Maximum time to read request headers | | |||
| 144 | + | | `traffic.http.idle_timeout` | `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT` | `"30s"` | Maximum keep-alive idle time between requests | | |||
| 145 | + | | `traffic.http.transfer_idle_timeout` | `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT` | `"2m"` | Inactivity timeout for request bodies and responses | | |||
| 146 | + | ||||
| 147 | + | Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads. Servers pick up changes when the traffic graph is next applied (reload); connections already in flight keep their previous timeout. | |||
| 148 | + | ||||
| 149 | + | ### Inactivity timeout | |||
| 150 | + | ||||
| 151 | + | Each request has an inactivity timer, armed when the request starts. Progress resets it: a response chunk accepted by the client connection (8 KiB granularity), a flush, or request body bytes read. If `transfer_idle_timeout` passes without progress, Gordon cancels the request context, which aborts the upstream request of a reverse-proxied app, and closes the client connection. When the handler returns, the final response flush and the drain of an unread request body are bounded by the same duration. Consequences: | |||
| 152 | + | ||||
| 153 | + | - a response that streams data at least once per `transfer_idle_timeout` can last arbitrarily long, and a client that keeps reading is never cut (it must accept 8 KiB per `transfer_idle_timeout`, about 68 B/s at the 2 minute default); | |||
| 154 | + | - an upload whose body arrives at least once per `transfer_idle_timeout` can last arbitrarily long; | |||
| 155 | + | - a backend that stays silent longer than `transfer_idle_timeout` before or between response chunks has its connection cut, so set it above the slowest expected backend response time; | |||
| 156 | + | - the timer also fires when a handler or backend is silent and no write is pending; | |||
| 157 | + | - WebSocket and other upgraded connections are not subject to it, and HTTP/2 streams are handled the same way as HTTP/1. | |||
| 158 | + | ||||
| 159 | + | `transfer_idle_timeout` does not apply to `[traffic.tcp]` or `[traffic.udp]` routers; those use their own `idle_timeout` (see below). | |||
| 160 | + | ||||
| 130 | ## TCP Routers | 161 | ## TCP Routers | ||
| 131 | 162 | ||||
| 132 | Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`. | 163 | Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`. | ||
A internal/adapters/in/http/middleware/transfer_idle.go +282 -0
| @@ -0,0 +1,282 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "net/http" | |
| 10 | + | "sync" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // errTransferFinished is returned by writer calls made after the request | |
| 15 | + | // finished (or was hijacked). The controller is never touched in that state. | |
| 16 | + | var errTransferFinished = errors.New("transfer idle: request already finished") | |
| 17 | + | ||
| 18 | + | // idleWriteChunk is the progress unit of response writes. Each chunk is handed | |
| 19 | + | // to the connection separately and counts as progress once it has been fully | |
| 20 | + | // accepted, so a client that keeps consuming data is never cut while a client | |
| 21 | + | // that stops reading is cut after the idle timeout. The residual granularity is | |
| 22 | + | // one chunk: a client must accept idleWriteChunk bytes within one idle period, | |
| 23 | + | // which is a floor of idleWriteChunk/idle bytes per second (about 68 B/s for | |
| 24 | + | // the 2 minute default). | |
| 25 | + | const idleWriteChunk = 8 * 1024 | |
| 26 | + | ||
| 27 | + | // TransferIdleTimeout returns a middleware that replaces fixed per-request | |
| 28 | + | // read/write timeouts with a request-scoped inactivity timeout. The server it | |
| 29 | + | // wraps must not set ReadTimeout or WriteTimeout. | |
| 30 | + | // | |
| 31 | + | // A single timer covers the request. Progress renews it: a response chunk | |
| 32 | + | // accepted by the connection, a flush, or request body bytes read. When idle | |
| 33 | + | // elapses without progress, the middleware cancels the request context (so a | |
| 34 | + | // ReverseProxy aborts its upstream request) and expires the connection's read | |
| 35 | + | // and write deadlines, which unblocks pending I/O and makes the server drop the | |
| 36 | + | // connection once the handler returns. The timer also covers a handler that is | |
| 37 | + | // silent and not blocked in any I/O, which a plain write deadline cannot. | |
| 38 | + | // | |
| 39 | + | // The timer is armed when the request starts, so the wait for the first | |
| 40 | + | // response byte is bounded too. It is stopped when the handler returns and when | |
| 41 | + | // the connection is hijacked: upgraded streams (WebSocket) are exempt. | |
| 42 | + | // Deadlines go through http.ResponseController, so HTTP/1.x and HTTP/2 both | |
| 43 | + | // work; Flush, Hijack and Unwrap are preserved. | |
| 44 | + | // | |
| 45 | + | // net/http does work after the handler returns that can block on a stalled | |
| 46 | + | // client: the final response flush and the drain of an unread request body. | |
| 47 | + | // With no server Read/WriteTimeout those would hang forever, so finish arms | |
| 48 | + | // one last bounded read and write deadline (idle from now) before the handler | |
| 49 | + | // returns and leaves it in place. net/http clears the write deadline itself | |
| 50 | + | // after the response is finished and replaces the read deadline with the | |
| 51 | + | // keep-alive idle / header deadline before the next request, so nothing leaks | |
| 52 | + | // into the next request. The timer never touches the controller after return. | |
| 53 | + | // | |
| 54 | + | // Limits: a handler that ignores the canceled context and never touches the | |
| 55 | + | // connection again keeps its HTTP/1 connection until it returns. A handler that | |
| 56 | + | // enables HTTP/1 full-duplex mode (ResponseController.EnableFullDuplex) is not | |
| 57 | + | // covered for the unread-body drain: net/http clears the read deadline before | |
| 58 | + | // that drain, so such a handler must consume or bound its request body itself. | |
| 59 | + | // A non-positive idle disables the middleware. | |
| 60 | + | func TransferIdleTimeout(idle time.Duration) func(http.Handler) http.Handler { | |
| 61 | + | return func(next http.Handler) http.Handler { | |
| 62 | + | if idle <= 0 { | |
| 63 | + | return next | |
| 64 | + | } | |
| 65 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 66 | + | ctx, cancel := context.WithCancel(r.Context()) | |
| 67 | + | hasBody := r.Body != nil && r.Body != http.NoBody | |
| 68 | + | state := newIdleState(idle, http.NewResponseController(w), cancel, hasBody) | |
| 69 | + | // finish marks the state done under the mutex before this | |
| 70 | + | // function returns, so nothing touches the controller afterwards. | |
| 71 | + | defer state.finish() | |
| 72 | + | ||
| 73 | + | req := r.WithContext(ctx) | |
| 74 | + | if hasBody { | |
| 75 | + | req.Body = &idleBody{ReadCloser: r.Body, state: state} | |
| 76 | + | } | |
| 77 | + | next.ServeHTTP(&idleResponseWriter{ResponseWriter: w, state: state}, req) | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // idleState is the request-lifetime state shared by the writer, the body and | |
| 83 | + | // the timer. The controller is only used while the state is live, under mu. | |
| 84 | + | type idleState struct { | |
| 85 | + | idle time.Duration | |
| 86 | + | rc *http.ResponseController | |
| 87 | + | cancel context.CancelFunc | |
| 88 | + | ||
| 89 | + | hasBody bool // request has a body that net/http may still have to drain | |
| 90 | + | ||
| 91 | + | mu sync.Mutex | |
| 92 | + | timer *time.Timer | |
| 93 | + | last time.Time | |
| 94 | + | done bool // handler returned or connection hijacked | |
| 95 | + | expired bool // the timer fired and expired the connection deadlines | |
| 96 | + | hijacked bool | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func newIdleState(idle time.Duration, rc *http.ResponseController, cancel context.CancelFunc, hasBody bool) *idleState { | |
| 100 | + | s := &idleState{idle: idle, rc: rc, cancel: cancel, last: time.Now(), hasBody: hasBody} | |
| 101 | + | s.mu.Lock() | |
| 102 | + | s.timer = time.AfterFunc(idle, s.onTimer) | |
| 103 | + | s.mu.Unlock() | |
| 104 | + | return s | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // progress records activity. It never touches the controller. | |
| 108 | + | func (s *idleState) progress() { | |
| 109 | + | s.mu.Lock() | |
| 110 | + | if !s.done { | |
| 111 | + | s.last = time.Now() | |
| 112 | + | } | |
| 113 | + | s.mu.Unlock() | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // live reports whether the request is still being served. | |
| 117 | + | func (s *idleState) live() bool { | |
| 118 | + | s.mu.Lock() | |
| 119 | + | defer s.mu.Unlock() | |
| 120 | + | return !s.done | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // onTimer runs on timer expiry. Progress only stamps s.last, so the timer is | |
| 124 | + | // re-armed for the remainder instead of being reset on every event; this also | |
| 125 | + | // makes progress racing with expiry safe. | |
| 126 | + | func (s *idleState) onTimer() { | |
| 127 | + | s.mu.Lock() | |
| 128 | + | defer s.mu.Unlock() | |
| 129 | + | if s.done { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | if remaining := s.idle - time.Since(s.last); remaining > 0 { | |
| 133 | + | s.timer.Reset(remaining) | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | s.expired = true | |
| 137 | + | s.cancel() | |
| 138 | + | // Held under mu so finish() cannot complete while these run. | |
| 139 | + | now := time.Now() | |
| 140 | + | _ = s.rc.SetWriteDeadline(now) | |
| 141 | + | _ = s.rc.SetReadDeadline(now) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // finish ends the request lifetime. After it returns, no code path calls the | |
| 145 | + | // controller. It is safe to call more than once. Unless the connection was | |
| 146 | + | // hijacked or already expired, it first arms bounded deadlines so net/http's | |
| 147 | + | // post-handler work (final flush, unread body drain) cannot block forever. | |
| 148 | + | func (s *idleState) finish() { | |
| 149 | + | s.mu.Lock() | |
| 150 | + | wasDone := s.done | |
| 151 | + | s.done = true | |
| 152 | + | s.timer.Stop() | |
| 153 | + | if !wasDone && !s.expired && !s.hijacked { | |
| 154 | + | deadline := time.Now().Add(s.idle) | |
| 155 | + | _ = s.rc.SetWriteDeadline(deadline) | |
| 156 | + | if s.hasBody { | |
| 157 | + | _ = s.rc.SetReadDeadline(deadline) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | s.mu.Unlock() | |
| 161 | + | s.cancel() | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // flush flushes through the controller unless the request finished. The mutex | |
| 165 | + | // is not held during the flush: it may block on a slow client, and the timer | |
| 166 | + | // callback needs the mutex to expire the deadlines that unblock it. net/http | |
| 167 | + | // forbids using the writer after the handler returned or concurrently with its | |
| 168 | + | // return, so the live check is a guard against misuse, not a synchronisation | |
| 169 | + | // point for the flush itself. | |
| 170 | + | func (s *idleState) flush() error { | |
| 171 | + | if !s.live() { | |
| 172 | + | return errTransferFinished | |
| 173 | + | } | |
| 174 | + | return s.rc.Flush() | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // hijack hijacks the connection without holding the mutex: HTTP/1 Hijack may | |
| 178 | + | // flush pending headers and block on a client that does not read, and the timer | |
| 179 | + | // needs the mutex to expire the deadlines that unblock it. Enforcement stays | |
| 180 | + | // active until the hijack succeeds; only then is the timer stopped. If the | |
| 181 | + | // timer fired meanwhile, the deadlines it set on the connection are cleared so | |
| 182 | + | // the upgraded stream is not cut. | |
| 183 | + | func (s *idleState) hijack() (net.Conn, *bufio.ReadWriter, error) { | |
| 184 | + | if !s.live() { | |
| 185 | + | return nil, nil, errTransferFinished | |
| 186 | + | } | |
| 187 | + | conn, rw, err := s.rc.Hijack() | |
| 188 | + | if err != nil { | |
| 189 | + | return conn, rw, err | |
| 190 | + | } | |
| 191 | + | s.mu.Lock() | |
| 192 | + | s.done = true | |
| 193 | + | s.hijacked = true | |
| 194 | + | s.timer.Stop() | |
| 195 | + | expired := s.expired | |
| 196 | + | s.mu.Unlock() | |
| 197 | + | if expired { | |
| 198 | + | _ = conn.SetDeadline(time.Time{}) | |
| 199 | + | } | |
| 200 | + | return conn, rw, nil | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // idleResponseWriter reports response progress. | |
| 204 | + | type idleResponseWriter struct { | |
| 205 | + | http.ResponseWriter | |
| 206 | + | state *idleState | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // Write hands p to the connection in idleWriteChunk pieces and records progress | |
| 210 | + | // after each piece. | |
| 211 | + | func (w *idleResponseWriter) Write(p []byte) (int, error) { | |
| 212 | + | if !w.state.live() { | |
| 213 | + | return 0, errTransferFinished | |
| 214 | + | } | |
| 215 | + | if len(p) == 0 { | |
| 216 | + | return w.ResponseWriter.Write(p) | |
| 217 | + | } | |
| 218 | + | written := 0 | |
| 219 | + | for written < len(p) { | |
| 220 | + | chunk := p[written:min(written+idleWriteChunk, len(p))] | |
| 221 | + | n, err := w.ResponseWriter.Write(chunk) | |
| 222 | + | written += n | |
| 223 | + | if n > 0 { | |
| 224 | + | w.state.progress() | |
| 225 | + | } | |
| 226 | + | if err != nil { | |
| 227 | + | return written, err | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | return written, nil | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // FlushError flushes and records progress. It lets http.ResponseController | |
| 234 | + | // report flush failures. | |
| 235 | + | func (w *idleResponseWriter) FlushError() error { | |
| 236 | + | err := w.state.flush() | |
| 237 | + | if err == nil { | |
| 238 | + | w.state.progress() | |
| 239 | + | } | |
| 240 | + | return err | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Flush implements http.Flusher. | |
| 244 | + | func (w *idleResponseWriter) Flush() { | |
| 245 | + | _ = w.FlushError() | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // Hijack implements http.Hijacker and stops the inactivity timer: upgraded | |
| 249 | + | // streams (e.g. WebSocket) are exempt. | |
| 250 | + | func (w *idleResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { | |
| 251 | + | return w.state.hijack() | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // Unwrap exposes the underlying writer to http.ResponseController. | |
| 255 | + | func (w *idleResponseWriter) Unwrap() http.ResponseWriter { | |
| 256 | + | return w.ResponseWriter | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // idleBody reports request body progress. It only records timestamps, so it is | |
| 260 | + | // safe to Read or Close after the handler returned (the HTTP/2 transport closes | |
| 261 | + | // the outbound body asynchronously). | |
| 262 | + | type idleBody struct { | |
| 263 | + | io.ReadCloser | |
| 264 | + | state *idleState | |
| 265 | + | ||
| 266 | + | closeOnce sync.Once | |
| 267 | + | closeErr error | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func (b *idleBody) Read(p []byte) (int, error) { | |
| 271 | + | n, err := b.ReadCloser.Read(p) | |
| 272 | + | if n > 0 { | |
| 273 | + | b.state.progress() | |
| 274 | + | } | |
| 275 | + | return n, err | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // Close is idempotent. | |
| 279 | + | func (b *idleBody) Close() error { | |
| 280 | + | b.closeOnce.Do(func() { b.closeErr = b.ReadCloser.Close() }) | |
| 281 | + | return b.closeErr | |
| 282 | + | } | |
| @@ -0,0 +1,282 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "net" | |||
| 9 | + | "net/http" | |||
| 10 | + | "sync" | |||
| 11 | + | "time" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // errTransferFinished is returned by writer calls made after the request | |||
| 15 | + | // finished (or was hijacked). The controller is never touched in that state. | |||
| 16 | + | var errTransferFinished = errors.New("transfer idle: request already finished") | |||
| 17 | + | ||||
| 18 | + | // idleWriteChunk is the progress unit of response writes. Each chunk is handed | |||
| 19 | + | // to the connection separately and counts as progress once it has been fully | |||
| 20 | + | // accepted, so a client that keeps consuming data is never cut while a client | |||
| 21 | + | // that stops reading is cut after the idle timeout. The residual granularity is | |||
| 22 | + | // one chunk: a client must accept idleWriteChunk bytes within one idle period, | |||
| 23 | + | // which is a floor of idleWriteChunk/idle bytes per second (about 68 B/s for | |||
| 24 | + | // the 2 minute default). | |||
| 25 | + | const idleWriteChunk = 8 * 1024 | |||
| 26 | + | ||||
| 27 | + | // TransferIdleTimeout returns a middleware that replaces fixed per-request | |||
| 28 | + | // read/write timeouts with a request-scoped inactivity timeout. The server it | |||
| 29 | + | // wraps must not set ReadTimeout or WriteTimeout. | |||
| 30 | + | // | |||
| 31 | + | // A single timer covers the request. Progress renews it: a response chunk | |||
| 32 | + | // accepted by the connection, a flush, or request body bytes read. When idle | |||
| 33 | + | // elapses without progress, the middleware cancels the request context (so a | |||
| 34 | + | // ReverseProxy aborts its upstream request) and expires the connection's read | |||
| 35 | + | // and write deadlines, which unblocks pending I/O and makes the server drop the | |||
| 36 | + | // connection once the handler returns. The timer also covers a handler that is | |||
| 37 | + | // silent and not blocked in any I/O, which a plain write deadline cannot. | |||
| 38 | + | // | |||
| 39 | + | // The timer is armed when the request starts, so the wait for the first | |||
| 40 | + | // response byte is bounded too. It is stopped when the handler returns and when | |||
| 41 | + | // the connection is hijacked: upgraded streams (WebSocket) are exempt. | |||
| 42 | + | // Deadlines go through http.ResponseController, so HTTP/1.x and HTTP/2 both | |||
| 43 | + | // work; Flush, Hijack and Unwrap are preserved. | |||
| 44 | + | // | |||
| 45 | + | // net/http does work after the handler returns that can block on a stalled | |||
| 46 | + | // client: the final response flush and the drain of an unread request body. | |||
| 47 | + | // With no server Read/WriteTimeout those would hang forever, so finish arms | |||
| 48 | + | // one last bounded read and write deadline (idle from now) before the handler | |||
| 49 | + | // returns and leaves it in place. net/http clears the write deadline itself | |||
| 50 | + | // after the response is finished and replaces the read deadline with the | |||
| 51 | + | // keep-alive idle / header deadline before the next request, so nothing leaks | |||
| 52 | + | // into the next request. The timer never touches the controller after return. | |||
| 53 | + | // | |||
| 54 | + | // Limits: a handler that ignores the canceled context and never touches the | |||
| 55 | + | // connection again keeps its HTTP/1 connection until it returns. A handler that | |||
| 56 | + | // enables HTTP/1 full-duplex mode (ResponseController.EnableFullDuplex) is not | |||
| 57 | + | // covered for the unread-body drain: net/http clears the read deadline before | |||
| 58 | + | // that drain, so such a handler must consume or bound its request body itself. | |||
| 59 | + | // A non-positive idle disables the middleware. | |||
| 60 | + | func TransferIdleTimeout(idle time.Duration) func(http.Handler) http.Handler { | |||
| 61 | + | return func(next http.Handler) http.Handler { | |||
| 62 | + | if idle <= 0 { | |||
| 63 | + | return next | |||
| 64 | + | } | |||
| 65 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 66 | + | ctx, cancel := context.WithCancel(r.Context()) | |||
| 67 | + | hasBody := r.Body != nil && r.Body != http.NoBody | |||
| 68 | + | state := newIdleState(idle, http.NewResponseController(w), cancel, hasBody) | |||
| 69 | + | // finish marks the state done under the mutex before this | |||
| 70 | + | // function returns, so nothing touches the controller afterwards. | |||
| 71 | + | defer state.finish() | |||
| 72 | + | ||||
| 73 | + | req := r.WithContext(ctx) | |||
| 74 | + | if hasBody { | |||
| 75 | + | req.Body = &idleBody{ReadCloser: r.Body, state: state} | |||
| 76 | + | } | |||
| 77 | + | next.ServeHTTP(&idleResponseWriter{ResponseWriter: w, state: state}, req) | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // idleState is the request-lifetime state shared by the writer, the body and | |||
| 83 | + | // the timer. The controller is only used while the state is live, under mu. | |||
| 84 | + | type idleState struct { | |||
| 85 | + | idle time.Duration | |||
| 86 | + | rc *http.ResponseController | |||
| 87 | + | cancel context.CancelFunc | |||
| 88 | + | ||||
| 89 | + | hasBody bool // request has a body that net/http may still have to drain | |||
| 90 | + | ||||
| 91 | + | mu sync.Mutex | |||
| 92 | + | timer *time.Timer | |||
| 93 | + | last time.Time | |||
| 94 | + | done bool // handler returned or connection hijacked | |||
| 95 | + | expired bool // the timer fired and expired the connection deadlines | |||
| 96 | + | hijacked bool | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func newIdleState(idle time.Duration, rc *http.ResponseController, cancel context.CancelFunc, hasBody bool) *idleState { | |||
| 100 | + | s := &idleState{idle: idle, rc: rc, cancel: cancel, last: time.Now(), hasBody: hasBody} | |||
| 101 | + | s.mu.Lock() | |||
| 102 | + | s.timer = time.AfterFunc(idle, s.onTimer) | |||
| 103 | + | s.mu.Unlock() | |||
| 104 | + | return s | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // progress records activity. It never touches the controller. | |||
| 108 | + | func (s *idleState) progress() { | |||
| 109 | + | s.mu.Lock() | |||
| 110 | + | if !s.done { | |||
| 111 | + | s.last = time.Now() | |||
| 112 | + | } | |||
| 113 | + | s.mu.Unlock() | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // live reports whether the request is still being served. | |||
| 117 | + | func (s *idleState) live() bool { | |||
| 118 | + | s.mu.Lock() | |||
| 119 | + | defer s.mu.Unlock() | |||
| 120 | + | return !s.done | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // onTimer runs on timer expiry. Progress only stamps s.last, so the timer is | |||
| 124 | + | // re-armed for the remainder instead of being reset on every event; this also | |||
| 125 | + | // makes progress racing with expiry safe. | |||
| 126 | + | func (s *idleState) onTimer() { | |||
| 127 | + | s.mu.Lock() | |||
| 128 | + | defer s.mu.Unlock() | |||
| 129 | + | if s.done { | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | if remaining := s.idle - time.Since(s.last); remaining > 0 { | |||
| 133 | + | s.timer.Reset(remaining) | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | s.expired = true | |||
| 137 | + | s.cancel() | |||
| 138 | + | // Held under mu so finish() cannot complete while these run. | |||
| 139 | + | now := time.Now() | |||
| 140 | + | _ = s.rc.SetWriteDeadline(now) | |||
| 141 | + | _ = s.rc.SetReadDeadline(now) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // finish ends the request lifetime. After it returns, no code path calls the | |||
| 145 | + | // controller. It is safe to call more than once. Unless the connection was | |||
| 146 | + | // hijacked or already expired, it first arms bounded deadlines so net/http's | |||
| 147 | + | // post-handler work (final flush, unread body drain) cannot block forever. | |||
| 148 | + | func (s *idleState) finish() { | |||
| 149 | + | s.mu.Lock() | |||
| 150 | + | wasDone := s.done | |||
| 151 | + | s.done = true | |||
| 152 | + | s.timer.Stop() | |||
| 153 | + | if !wasDone && !s.expired && !s.hijacked { | |||
| 154 | + | deadline := time.Now().Add(s.idle) | |||
| 155 | + | _ = s.rc.SetWriteDeadline(deadline) | |||
| 156 | + | if s.hasBody { | |||
| 157 | + | _ = s.rc.SetReadDeadline(deadline) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | s.mu.Unlock() | |||
| 161 | + | s.cancel() | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // flush flushes through the controller unless the request finished. The mutex | |||
| 165 | + | // is not held during the flush: it may block on a slow client, and the timer | |||
| 166 | + | // callback needs the mutex to expire the deadlines that unblock it. net/http | |||
| 167 | + | // forbids using the writer after the handler returned or concurrently with its | |||
| 168 | + | // return, so the live check is a guard against misuse, not a synchronisation | |||
| 169 | + | // point for the flush itself. | |||
| 170 | + | func (s *idleState) flush() error { | |||
| 171 | + | if !s.live() { | |||
| 172 | + | return errTransferFinished | |||
| 173 | + | } | |||
| 174 | + | return s.rc.Flush() | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // hijack hijacks the connection without holding the mutex: HTTP/1 Hijack may | |||
| 178 | + | // flush pending headers and block on a client that does not read, and the timer | |||
| 179 | + | // needs the mutex to expire the deadlines that unblock it. Enforcement stays | |||
| 180 | + | // active until the hijack succeeds; only then is the timer stopped. If the | |||
| 181 | + | // timer fired meanwhile, the deadlines it set on the connection are cleared so | |||
| 182 | + | // the upgraded stream is not cut. | |||
| 183 | + | func (s *idleState) hijack() (net.Conn, *bufio.ReadWriter, error) { | |||
| 184 | + | if !s.live() { | |||
| 185 | + | return nil, nil, errTransferFinished | |||
| 186 | + | } | |||
| 187 | + | conn, rw, err := s.rc.Hijack() | |||
| 188 | + | if err != nil { | |||
| 189 | + | return conn, rw, err | |||
| 190 | + | } | |||
| 191 | + | s.mu.Lock() | |||
| 192 | + | s.done = true | |||
| 193 | + | s.hijacked = true | |||
| 194 | + | s.timer.Stop() | |||
| 195 | + | expired := s.expired | |||
| 196 | + | s.mu.Unlock() | |||
| 197 | + | if expired { | |||
| 198 | + | _ = conn.SetDeadline(time.Time{}) | |||
| 199 | + | } | |||
| 200 | + | return conn, rw, nil | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // idleResponseWriter reports response progress. | |||
| 204 | + | type idleResponseWriter struct { | |||
| 205 | + | http.ResponseWriter | |||
| 206 | + | state *idleState | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // Write hands p to the connection in idleWriteChunk pieces and records progress | |||
| 210 | + | // after each piece. | |||
| 211 | + | func (w *idleResponseWriter) Write(p []byte) (int, error) { | |||
| 212 | + | if !w.state.live() { | |||
| 213 | + | return 0, errTransferFinished | |||
| 214 | + | } | |||
| 215 | + | if len(p) == 0 { | |||
| 216 | + | return w.ResponseWriter.Write(p) | |||
| 217 | + | } | |||
| 218 | + | written := 0 | |||
| 219 | + | for written < len(p) { | |||
| 220 | + | chunk := p[written:min(written+idleWriteChunk, len(p))] | |||
| 221 | + | n, err := w.ResponseWriter.Write(chunk) | |||
| 222 | + | written += n | |||
| 223 | + | if n > 0 { | |||
| 224 | + | w.state.progress() | |||
| 225 | + | } | |||
| 226 | + | if err != nil { | |||
| 227 | + | return written, err | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | return written, nil | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // FlushError flushes and records progress. It lets http.ResponseController | |||
| 234 | + | // report flush failures. | |||
| 235 | + | func (w *idleResponseWriter) FlushError() error { | |||
| 236 | + | err := w.state.flush() | |||
| 237 | + | if err == nil { | |||
| 238 | + | w.state.progress() | |||
| 239 | + | } | |||
| 240 | + | return err | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // Flush implements http.Flusher. | |||
| 244 | + | func (w *idleResponseWriter) Flush() { | |||
| 245 | + | _ = w.FlushError() | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // Hijack implements http.Hijacker and stops the inactivity timer: upgraded | |||
| 249 | + | // streams (e.g. WebSocket) are exempt. | |||
| 250 | + | func (w *idleResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { | |||
| 251 | + | return w.state.hijack() | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // Unwrap exposes the underlying writer to http.ResponseController. | |||
| 255 | + | func (w *idleResponseWriter) Unwrap() http.ResponseWriter { | |||
| 256 | + | return w.ResponseWriter | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // idleBody reports request body progress. It only records timestamps, so it is | |||
| 260 | + | // safe to Read or Close after the handler returned (the HTTP/2 transport closes | |||
| 261 | + | // the outbound body asynchronously). | |||
| 262 | + | type idleBody struct { | |||
| 263 | + | io.ReadCloser | |||
| 264 | + | state *idleState | |||
| 265 | + | ||||
| 266 | + | closeOnce sync.Once | |||
| 267 | + | closeErr error | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func (b *idleBody) Read(p []byte) (int, error) { | |||
| 271 | + | n, err := b.ReadCloser.Read(p) | |||
| 272 | + | if n > 0 { | |||
| 273 | + | b.state.progress() | |||
| 274 | + | } | |||
| 275 | + | return n, err | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // Close is idempotent. | |||
| 279 | + | func (b *idleBody) Close() error { | |||
| 280 | + | b.closeOnce.Do(func() { b.closeErr = b.ReadCloser.Close() }) | |||
| 281 | + | return b.closeErr | |||
| 282 | + | } | |||
A internal/adapters/in/http/middleware/transfer_idle_test.go +638 -0
| @@ -0,0 +1,638 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "crypto/tls" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 11 | + | "net/http/httputil" | |
| 12 | + | "net/url" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "sync/atomic" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/stretchr/testify/assert" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | const testIdle = 300 * time.Millisecond | |
| 24 | + | ||
| 25 | + | func newIdleServer(t *testing.T, handler http.Handler) *httptest.Server { | |
| 26 | + | t.Helper() | |
| 27 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(handler)) | |
| 28 | + | srv.Config.ReadHeaderTimeout = 5 * time.Second | |
| 29 | + | srv.Start() | |
| 30 | + | t.Cleanup(srv.Close) | |
| 31 | + | return srv | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestTransferIdleTimeout_SlowActiveDownloadCompletes(t *testing.T) { | |
| 35 | + | const chunks = 12 // 12 x 50ms = 600ms, twice the idle timeout | |
| 36 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 37 | + | for range chunks { | |
| 38 | + | _, _ = w.Write([]byte("x")) | |
| 39 | + | w.(http.Flusher).Flush() | |
| 40 | + | time.Sleep(50 * time.Millisecond) | |
| 41 | + | } | |
| 42 | + | })) | |
| 43 | + | ||
| 44 | + | start := time.Now() | |
| 45 | + | resp, err := http.Get(srv.URL) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | defer resp.Body.Close() | |
| 48 | + | body, err := io.ReadAll(resp.Body) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | assert.Len(t, body, chunks) | |
| 51 | + | assert.Greater(t, time.Since(start), testIdle, "transfer must outlast the idle timeout") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestTransferIdleTimeout_SilentResponseIsCut(t *testing.T) { | |
| 55 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 56 | + | _, _ = w.Write([]byte("start")) | |
| 57 | + | w.(http.Flusher).Flush() | |
| 58 | + | time.Sleep(3 * testIdle) | |
| 59 | + | _, _ = w.Write([]byte("late")) | |
| 60 | + | w.(http.Flusher).Flush() | |
| 61 | + | })) | |
| 62 | + | ||
| 63 | + | resp, err := http.Get(srv.URL) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | defer resp.Body.Close() | |
| 66 | + | body, err := io.ReadAll(resp.Body) | |
| 67 | + | require.Error(t, err, "connection must be cut after the idle timeout") | |
| 68 | + | assert.Equal(t, "start", string(body)) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestTransferIdleTimeout_SilentHandlerBeforeFirstByteIsCut(t *testing.T) { | |
| 72 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 73 | + | time.Sleep(3 * testIdle) | |
| 74 | + | _, _ = w.Write([]byte("late")) | |
| 75 | + | })) | |
| 76 | + | ||
| 77 | + | resp, err := http.Get(srv.URL) | |
| 78 | + | if err == nil { | |
| 79 | + | defer resp.Body.Close() | |
| 80 | + | _, err = io.ReadAll(resp.Body) | |
| 81 | + | } | |
| 82 | + | require.Error(t, err) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestTransferIdleTimeout_SlowActiveUploadCompletes(t *testing.T) { | |
| 86 | + | const chunks = 10 // 10 x 60ms = 600ms | |
| 87 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 88 | + | n, err := io.Copy(io.Discard, r.Body) | |
| 89 | + | if err != nil { | |
| 90 | + | http.Error(w, err.Error(), http.StatusBadRequest) | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | _, _ = w.Write([]byte(strings.Repeat("o", int(n)))) | |
| 94 | + | })) | |
| 95 | + | ||
| 96 | + | pr, pw := io.Pipe() | |
| 97 | + | go func() { | |
| 98 | + | for range chunks { | |
| 99 | + | _, _ = pw.Write([]byte("u")) | |
| 100 | + | time.Sleep(60 * time.Millisecond) | |
| 101 | + | } | |
| 102 | + | _ = pw.Close() | |
| 103 | + | }() | |
| 104 | + | resp, err := http.Post(srv.URL, "application/octet-stream", pr) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | defer resp.Body.Close() | |
| 107 | + | body, err := io.ReadAll(resp.Body) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 110 | + | assert.Len(t, body, chunks) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestTransferIdleTimeout_StalledUploadIsCut(t *testing.T) { | |
| 114 | + | var readErr atomic.Value | |
| 115 | + | done := make(chan struct{}) | |
| 116 | + | srv := newIdleServer(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { | |
| 117 | + | defer close(done) | |
| 118 | + | _, err := io.Copy(io.Discard, r.Body) | |
| 119 | + | if err != nil { | |
| 120 | + | readErr.Store(err) | |
| 121 | + | } | |
| 122 | + | })) | |
| 123 | + | ||
| 124 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | defer conn.Close() | |
| 127 | + | _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nabc")) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | ||
| 130 | + | select { | |
| 131 | + | case <-done: | |
| 132 | + | case <-time.After(5 * testIdle): | |
| 133 | + | t.Fatal("stalled upload was not cut") | |
| 134 | + | } | |
| 135 | + | assert.NotNil(t, readErr.Load(), "body read must fail after the idle timeout") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestTransferIdleTimeout_PreservesFlusherAndUnwrap(t *testing.T) { | |
| 139 | + | var isFlusher, unwrapped bool | |
| 140 | + | h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 141 | + | _, isFlusher = w.(http.Flusher) | |
| 142 | + | _, unwrapped = w.(interface{ Unwrap() http.ResponseWriter }) | |
| 143 | + | })) | |
| 144 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) | |
| 145 | + | assert.True(t, isFlusher) | |
| 146 | + | assert.True(t, unwrapped) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestTransferIdleTimeout_HijackStillWorks(t *testing.T) { | |
| 150 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 151 | + | hj, ok := w.(http.Hijacker) | |
| 152 | + | if !ok { | |
| 153 | + | http.Error(w, "no hijacker", http.StatusInternalServerError) | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | conn, rw, err := hj.Hijack() | |
| 157 | + | if err != nil { | |
| 158 | + | return | |
| 159 | + | } | |
| 160 | + | defer conn.Close() | |
| 161 | + | _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n") | |
| 162 | + | _ = rw.Flush() | |
| 163 | + | // Stay open longer than the idle timeout: hijacked streams are exempt. | |
| 164 | + | time.Sleep(2 * testIdle) | |
| 165 | + | _, _ = rw.WriteString("hello") | |
| 166 | + | _ = rw.Flush() | |
| 167 | + | })) | |
| 168 | + | ||
| 169 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |
| 170 | + | require.NoError(t, err) | |
| 171 | + | defer conn.Close() | |
| 172 | + | _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n")) | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | ||
| 175 | + | br := bufio.NewReader(conn) | |
| 176 | + | resp, err := http.ReadResponse(br, nil) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) | |
| 179 | + | buf := make([]byte, 5) | |
| 180 | + | _, err = io.ReadFull(br, buf) | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | assert.Equal(t, "hello", string(buf)) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestTransferIdleTimeout_ReverseProxyFlushesStream(t *testing.T) { | |
| 186 | + | // Flush propagation through a wrapped writer, as used by httputil.ReverseProxy. | |
| 187 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 188 | + | rc := http.NewResponseController(w) | |
| 189 | + | for range 8 { | |
| 190 | + | _, _ = w.Write([]byte(".")) | |
| 191 | + | if err := rc.Flush(); err != nil { | |
| 192 | + | return | |
| 193 | + | } | |
| 194 | + | time.Sleep(50 * time.Millisecond) | |
| 195 | + | } | |
| 196 | + | })) | |
| 197 | + | resp, err := http.Get(srv.URL) | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | defer resp.Body.Close() | |
| 200 | + | body, err := io.ReadAll(resp.Body) | |
| 201 | + | require.NoError(t, err) | |
| 202 | + | assert.Len(t, body, 8) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestTransferIdleTimeout_DisabledWhenNonPositive(t *testing.T) { | |
| 206 | + | called := false | |
| 207 | + | inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) | |
| 208 | + | h := TransferIdleTimeout(0)(inner) | |
| 209 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) | |
| 210 | + | assert.True(t, called) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func TestTransferIdleTimeout_HTTP2(t *testing.T) { | |
| 214 | + | const chunks = 12 | |
| 215 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 216 | + | for range chunks { | |
| 217 | + | _, _ = w.Write([]byte("x")) | |
| 218 | + | w.(http.Flusher).Flush() | |
| 219 | + | time.Sleep(50 * time.Millisecond) | |
| 220 | + | } | |
| 221 | + | }))) | |
| 222 | + | srv.EnableHTTP2 = true | |
| 223 | + | srv.StartTLS() | |
| 224 | + | defer srv.Close() | |
| 225 | + | ||
| 226 | + | client := srv.Client() | |
| 227 | + | resp, err := client.Get(srv.URL) | |
| 228 | + | require.NoError(t, err) | |
| 229 | + | defer resp.Body.Close() | |
| 230 | + | assert.Equal(t, 2, resp.ProtoMajor) | |
| 231 | + | body, err := io.ReadAll(resp.Body) | |
| 232 | + | require.NoError(t, err) | |
| 233 | + | assert.Len(t, body, chunks) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // pipeListener serves connections created with dial over net.Pipe, which has no | |
| 237 | + | // kernel buffering: a write completes only when the peer reads it. | |
| 238 | + | type pipeListener struct { | |
| 239 | + | conns chan net.Conn | |
| 240 | + | done chan struct{} | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func newPipeListener() *pipeListener { | |
| 244 | + | return &pipeListener{conns: make(chan net.Conn), done: make(chan struct{})} | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func (l *pipeListener) Accept() (net.Conn, error) { | |
| 248 | + | select { | |
| 249 | + | case c := <-l.conns: | |
| 250 | + | return c, nil | |
| 251 | + | case <-l.done: | |
| 252 | + | return nil, net.ErrClosed | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (l *pipeListener) Close() error { | |
| 257 | + | select { | |
| 258 | + | case <-l.done: | |
| 259 | + | default: | |
| 260 | + | close(l.done) | |
| 261 | + | } | |
| 262 | + | return nil | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func (l *pipeListener) Addr() net.Addr { return pipeAddr{} } | |
| 266 | + | ||
| 267 | + | func (l *pipeListener) dial() net.Conn { | |
| 268 | + | client, server := net.Pipe() | |
| 269 | + | l.conns <- server | |
| 270 | + | return client | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | type pipeAddr struct{} | |
| 274 | + | ||
| 275 | + | func (pipeAddr) Network() string { return "pipe" } | |
| 276 | + | func (pipeAddr) String() string { return "pipe" } | |
| 277 | + | ||
| 278 | + | // slowReadConn reads at most 4 KiB every pause, like a congested client. | |
| 279 | + | type slowReadConn struct { | |
| 280 | + | net.Conn | |
| 281 | + | pause time.Duration | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (c *slowReadConn) Read(p []byte) (int, error) { | |
| 285 | + | time.Sleep(c.pause) | |
| 286 | + | return c.Conn.Read(p[:min(len(p), 4096)]) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func servePipe(t *testing.T, handler http.Handler) *pipeListener { | |
| 290 | + | t.Helper() | |
| 291 | + | ln := newPipeListener() | |
| 292 | + | srv := &http.Server{Handler: TransferIdleTimeout(testIdle)(handler), ReadHeaderTimeout: 5 * time.Second} | |
| 293 | + | go func() { _ = srv.Serve(ln) }() | |
| 294 | + | t.Cleanup(func() { _ = srv.Close() }) | |
| 295 | + | return ln | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestTransferIdleTimeout_BackpressuredButReadingClientIsNotCut(t *testing.T) { | |
| 299 | + | const size = 64 * 1024 | |
| 300 | + | ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 301 | + | _, _ = w.Write([]byte(strings.Repeat("d", size))) | |
| 302 | + | })) | |
| 303 | + | client := &http.Client{Transport: &http.Transport{ | |
| 304 | + | DisableKeepAlives: true, | |
| 305 | + | DialContext: func(context.Context, string, string) (net.Conn, error) { | |
| 306 | + | return &slowReadConn{Conn: ln.dial(), pause: 25 * time.Millisecond}, nil | |
| 307 | + | }, | |
| 308 | + | }} | |
| 309 | + | ||
| 310 | + | start := time.Now() | |
| 311 | + | resp, err := client.Get("http://pipe/") | |
| 312 | + | require.NoError(t, err) | |
| 313 | + | defer resp.Body.Close() | |
| 314 | + | body, err := io.ReadAll(resp.Body) | |
| 315 | + | require.NoError(t, err, "a client that keeps reading must not be cut") | |
| 316 | + | assert.Len(t, body, size) | |
| 317 | + | assert.Greater(t, time.Since(start), testIdle, "the transfer must outlast the idle timeout") | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func TestTransferIdleTimeout_NonReadingClientUnblocksWrite(t *testing.T) { | |
| 321 | + | writeErr := make(chan error, 1) | |
| 322 | + | ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 323 | + | _, err := w.Write([]byte(strings.Repeat("d", 1<<20))) | |
| 324 | + | writeErr <- err | |
| 325 | + | })) | |
| 326 | + | conn := ln.dial() | |
| 327 | + | defer conn.Close() | |
| 328 | + | _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n")) | |
| 329 | + | require.NoError(t, err) | |
| 330 | + | ||
| 331 | + | select { | |
| 332 | + | case err := <-writeErr: | |
| 333 | + | require.Error(t, err, "write to a client that never reads must fail") | |
| 334 | + | case <-time.After(10 * testIdle): | |
| 335 | + | t.Fatal("blocked write was not unblocked by the inactivity timeout") | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func TestTransferIdleTimeout_SilentHandlerContextCanceledAndConnectionClosed(t *testing.T) { | |
| 340 | + | ctxDone := make(chan struct{}) | |
| 341 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 342 | + | _, _ = w.Write([]byte("start")) | |
| 343 | + | w.(http.Flusher).Flush() | |
| 344 | + | <-r.Context().Done() // a ReverseProxy would abort its upstream request here | |
| 345 | + | close(ctxDone) | |
| 346 | + | })) | |
| 347 | + | ||
| 348 | + | client := &http.Client{Timeout: 10 * time.Second} | |
| 349 | + | begin := time.Now() | |
| 350 | + | resp, err := client.Get(srv.URL) | |
| 351 | + | require.NoError(t, err) | |
| 352 | + | defer resp.Body.Close() | |
| 353 | + | body, err := io.ReadAll(resp.Body) | |
| 354 | + | require.Error(t, err, "the client connection must be closed, not completed cleanly") | |
| 355 | + | assert.Equal(t, "start", string(body)) | |
| 356 | + | assert.Less(t, time.Since(begin), 5*time.Second) | |
| 357 | + | ||
| 358 | + | select { | |
| 359 | + | case <-ctxDone: | |
| 360 | + | case <-time.After(5 * time.Second): | |
| 361 | + | t.Fatal("request context was not canceled") | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func TestTransferIdleTimeout_ProgressKeepsContextAlive(t *testing.T) { | |
| 366 | + | var canceled atomic.Bool | |
| 367 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 368 | + | for range 12 { | |
| 369 | + | _, _ = w.Write([]byte("x")) | |
| 370 | + | w.(http.Flusher).Flush() | |
| 371 | + | select { | |
| 372 | + | case <-r.Context().Done(): | |
| 373 | + | canceled.Store(true) | |
| 374 | + | return | |
| 375 | + | case <-time.After(50 * time.Millisecond): | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | })) | |
| 379 | + | resp, err := http.Get(srv.URL) | |
| 380 | + | require.NoError(t, err) | |
| 381 | + | defer resp.Body.Close() | |
| 382 | + | body, err := io.ReadAll(resp.Body) | |
| 383 | + | require.NoError(t, err) | |
| 384 | + | assert.Len(t, body, 12) | |
| 385 | + | assert.False(t, canceled.Load()) | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func TestTransferIdleTimeout_BodyUseAfterHandlerReturnedIsSafe(t *testing.T) { | |
| 389 | + | var body io.ReadCloser | |
| 390 | + | h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { | |
| 391 | + | body = r.Body | |
| 392 | + | })) | |
| 393 | + | req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("payload")) | |
| 394 | + | h.ServeHTTP(httptest.NewRecorder(), req) | |
| 395 | + | ||
| 396 | + | // Like http.Transport closing the outbound body after the handler returned. | |
| 397 | + | require.NoError(t, body.Close()) | |
| 398 | + | require.NoError(t, body.Close(), "Close must be idempotent") | |
| 399 | + | _, _ = body.Read(make([]byte, 4)) | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // TestTransferIdleTimeout_HTTP2ReverseProxyEarlyResponseDuringUpload covers the | |
| 403 | + | // transport closing the outbound request body after the handler returned, when | |
| 404 | + | // the upstream answers early (413) while the client is still uploading. Run | |
| 405 | + | // with -race. | |
| 406 | + | func TestTransferIdleTimeout_HTTP2ReverseProxyEarlyResponseDuringUpload(t *testing.T) { | |
| 407 | + | upstream := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 408 | + | http.Error(w, "too large", http.StatusRequestEntityTooLarge) | |
| 409 | + | })) | |
| 410 | + | upstream.EnableHTTP2 = true | |
| 411 | + | upstream.StartTLS() | |
| 412 | + | defer upstream.Close() | |
| 413 | + | ||
| 414 | + | target, err := url.Parse(upstream.URL) | |
| 415 | + | require.NoError(t, err) | |
| 416 | + | proxy := httputil.NewSingleHostReverseProxy(target) | |
| 417 | + | proxy.Transport = &http.Transport{ | |
| 418 | + | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test upstream | |
| 419 | + | ForceAttemptHTTP2: true, | |
| 420 | + | } | |
| 421 | + | front := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(proxy)) | |
| 422 | + | front.EnableHTTP2 = true | |
| 423 | + | front.StartTLS() | |
| 424 | + | defer front.Close() | |
| 425 | + | ||
| 426 | + | var wg sync.WaitGroup | |
| 427 | + | for range 8 { | |
| 428 | + | wg.Add(1) | |
| 429 | + | go func() { | |
| 430 | + | defer wg.Done() | |
| 431 | + | for range 10 { | |
| 432 | + | uploadUntilEarlyResponse(t, front) | |
| 433 | + | } | |
| 434 | + | }() | |
| 435 | + | } | |
| 436 | + | wg.Wait() | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | func uploadUntilEarlyResponse(t *testing.T, front *httptest.Server) { | |
| 440 | + | t.Helper() | |
| 441 | + | pr, pw := io.Pipe() | |
| 442 | + | writerDone := make(chan struct{}) | |
| 443 | + | go func() { | |
| 444 | + | defer close(writerDone) | |
| 445 | + | chunk := []byte(strings.Repeat("u", 4096)) | |
| 446 | + | for range 200 { | |
| 447 | + | if _, err := pw.Write(chunk); err != nil { | |
| 448 | + | return | |
| 449 | + | } | |
| 450 | + | time.Sleep(time.Millisecond) | |
| 451 | + | } | |
| 452 | + | _ = pw.Close() | |
| 453 | + | }() | |
| 454 | + | ||
| 455 | + | defer func() { | |
| 456 | + | _ = pr.CloseWithError(io.ErrClosedPipe) | |
| 457 | + | <-writerDone | |
| 458 | + | }() | |
| 459 | + | ||
| 460 | + | req, err := http.NewRequest(http.MethodPost, front.URL, pr) | |
| 461 | + | if !assert.NoError(t, err) { | |
| 462 | + | return | |
| 463 | + | } | |
| 464 | + | resp, err := front.Client().Do(req) | |
| 465 | + | if !assert.NoError(t, err, "the early 413 must reach the client") { | |
| 466 | + | return | |
| 467 | + | } | |
| 468 | + | defer resp.Body.Close() | |
| 469 | + | assert.Equal(t, 2, resp.ProtoMajor) | |
| 470 | + | assert.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) | |
| 471 | + | _, _ = io.Copy(io.Discard, resp.Body) | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // TestTransferIdleTimeout_HTTP2BodyUsedAfterHandlerReturned uses the request | |
| 475 | + | // body after net/http's HTTP/2 server finished the handler (it drops its | |
| 476 | + | // response writer state then), as http.Transport does when it closes an | |
| 477 | + | // outbound body asynchronously. It must neither panic nor race. | |
| 478 | + | func TestTransferIdleTimeout_HTTP2BodyUsedAfterHandlerReturned(t *testing.T) { | |
| 479 | + | bodyCh := make(chan io.ReadCloser, 1) | |
| 480 | + | returned := make(chan struct{}) | |
| 481 | + | inner := TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 482 | + | bodyCh <- r.Body | |
| 483 | + | _, _ = w.Write([]byte("early")) | |
| 484 | + | })) | |
| 485 | + | srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 486 | + | inner.ServeHTTP(w, r) | |
| 487 | + | close(returned) | |
| 488 | + | })) | |
| 489 | + | srv.EnableHTTP2 = true | |
| 490 | + | srv.StartTLS() | |
| 491 | + | defer srv.Close() | |
| 492 | + | ||
| 493 | + | pr, pw := io.Pipe() | |
| 494 | + | defer pw.Close() | |
| 495 | + | req, err := http.NewRequest(http.MethodPost, srv.URL, pr) | |
| 496 | + | require.NoError(t, err) | |
| 497 | + | go func() { _, _ = pw.Write([]byte("partial")) }() | |
| 498 | + | resp, err := srv.Client().Do(req) | |
| 499 | + | require.NoError(t, err) | |
| 500 | + | _, _ = io.Copy(io.Discard, resp.Body) | |
| 501 | + | _ = resp.Body.Close() | |
| 502 | + | ||
| 503 | + | body := <-bodyCh | |
| 504 | + | <-returned | |
| 505 | + | time.Sleep(100 * time.Millisecond) // let the server loop process handlerDone | |
| 506 | + | ||
| 507 | + | _, _ = body.Read(make([]byte, 8)) | |
| 508 | + | require.NoError(t, body.Close()) | |
| 509 | + | require.NoError(t, body.Close()) | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | // trackedPipeServer serves net.Pipe connections and reports when the server | |
| 513 | + | // side closes one, so tests can assert a stalled connection is released. | |
| 514 | + | func trackedPipeServer(t *testing.T, handler http.Handler) (*pipeListener, <-chan struct{}) { | |
| 515 | + | t.Helper() | |
| 516 | + | closed := make(chan struct{}) | |
| 517 | + | var once sync.Once | |
| 518 | + | ln := newPipeListener() | |
| 519 | + | srv := &http.Server{ | |
| 520 | + | Handler: TransferIdleTimeout(testIdle)(handler), | |
| 521 | + | ReadHeaderTimeout: 5 * time.Second, | |
| 522 | + | ConnState: func(_ net.Conn, state http.ConnState) { | |
| 523 | + | if state == http.StateClosed { | |
| 524 | + | once.Do(func() { close(closed) }) | |
| 525 | + | } | |
| 526 | + | }, | |
| 527 | + | } | |
| 528 | + | go func() { _ = srv.Serve(ln) }() | |
| 529 | + | t.Cleanup(func() { _ = srv.Close() }) | |
| 530 | + | return ln, closed | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | func requireClosed(t *testing.T, closed <-chan struct{}, what string) { | |
| 534 | + | t.Helper() | |
| 535 | + | select { | |
| 536 | + | case <-closed: | |
| 537 | + | case <-time.After(20 * testIdle): | |
| 538 | + | t.Fatalf("server connection still held: %s", what) | |
| 539 | + | } | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | // The handler returns with a small unflushed response; net/http's final flush | |
| 543 | + | // then blocks on a client that never reads (net.Pipe has no buffering). | |
| 544 | + | func TestTransferIdleTimeout_FinalFlushToNonReadingClientIsBounded(t *testing.T) { | |
| 545 | + | ln, closed := trackedPipeServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 546 | + | _, _ = w.Write([]byte("small")) | |
| 547 | + | })) | |
| 548 | + | conn := ln.dial() | |
| 549 | + | defer conn.Close() | |
| 550 | + | _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n")) | |
| 551 | + | require.NoError(t, err) | |
| 552 | + | ||
| 553 | + | requireClosed(t, closed, "final response flush to a non-reading client") | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | // The client declares a body it never sends and the handler answers without | |
| 557 | + | // reading it; net/http then tries to drain the unread body. | |
| 558 | + | func TestTransferIdleTimeout_UnreadDeclaredBodyDrainIsBounded(t *testing.T) { | |
| 559 | + | ln, closed := trackedPipeServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 560 | + | http.Error(w, "unauthorized", http.StatusUnauthorized) | |
| 561 | + | })) | |
| 562 | + | conn := ln.dial() | |
| 563 | + | defer conn.Close() | |
| 564 | + | go func() { _, _ = io.Copy(io.Discard, conn) }() // read the response, then stay silent | |
| 565 | + | _, err := conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n")) | |
| 566 | + | require.NoError(t, err) | |
| 567 | + | ||
| 568 | + | requireClosed(t, closed, "drain of an unread declared request body") | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | func TestTransferIdleTimeout_UnreadDeclaredBodyDrainIsBoundedOverTCP(t *testing.T) { | |
| 572 | + | closed := make(chan struct{}) | |
| 573 | + | var once sync.Once | |
| 574 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 575 | + | http.Error(w, "unauthorized", http.StatusUnauthorized) | |
| 576 | + | }))) | |
| 577 | + | srv.Config.ConnState = func(_ net.Conn, state http.ConnState) { | |
| 578 | + | if state == http.StateClosed { | |
| 579 | + | once.Do(func() { close(closed) }) | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | srv.Start() | |
| 583 | + | defer srv.Close() | |
| 584 | + | ||
| 585 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |
| 586 | + | require.NoError(t, err) | |
| 587 | + | defer conn.Close() | |
| 588 | + | _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n")) | |
| 589 | + | require.NoError(t, err) | |
| 590 | + | ||
| 591 | + | requireClosed(t, closed, "drain of an unread declared request body over TCP") | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | // Leftover deadlines armed at handler return must not leak into the next | |
| 595 | + | // keep-alive request on the same connection. | |
| 596 | + | func TestTransferIdleTimeout_KeepAliveNextRequestNotAffectedByLeftoverDeadlines(t *testing.T) { | |
| 597 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 598 | + | _, _ = io.Copy(io.Discard, r.Body) | |
| 599 | + | _, _ = w.Write([]byte("ok")) | |
| 600 | + | }))) | |
| 601 | + | srv.Config.IdleTimeout = 10 * time.Second | |
| 602 | + | srv.Start() | |
| 603 | + | defer srv.Close() | |
| 604 | + | ||
| 605 | + | client := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 1}} | |
| 606 | + | for i := range 3 { | |
| 607 | + | resp, err := client.Post(srv.URL, "text/plain", strings.NewReader("body")) | |
| 608 | + | require.NoError(t, err, "request %d", i) | |
| 609 | + | _, err = io.ReadAll(resp.Body) | |
| 610 | + | require.NoError(t, err) | |
| 611 | + | _ = resp.Body.Close() | |
| 612 | + | time.Sleep(2 * testIdle) // outlive the leftover deadline while the connection idles | |
| 613 | + | } | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | // Hijack flushes pending headers; it must not hold the timer's mutex while it | |
| 617 | + | // blocks on a client that does not read. | |
| 618 | + | func TestTransferIdleTimeout_HijackBlockedOnNonReadingClientIsBounded(t *testing.T) { | |
| 619 | + | hijackErr := make(chan error, 1) | |
| 620 | + | ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 621 | + | w.WriteHeader(http.StatusOK) | |
| 622 | + | conn, _, err := w.(http.Hijacker).Hijack() | |
| 623 | + | if conn != nil { | |
| 624 | + | _ = conn.Close() | |
| 625 | + | } | |
| 626 | + | hijackErr <- err | |
| 627 | + | })) | |
| 628 | + | conn := ln.dial() | |
| 629 | + | defer conn.Close() | |
| 630 | + | _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n")) | |
| 631 | + | require.NoError(t, err) | |
| 632 | + | ||
| 633 | + | select { | |
| 634 | + | case <-hijackErr: | |
| 635 | + | case <-time.After(20 * testIdle): | |
| 636 | + | t.Fatal("Hijack blocked on a non-reading client was not bounded") | |
| 637 | + | } | |
| 638 | + | } | |
| @@ -0,0 +1,638 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "crypto/tls" | |||
| 7 | + | "io" | |||
| 8 | + | "net" | |||
| 9 | + | "net/http" | |||
| 10 | + | "net/http/httptest" | |||
| 11 | + | "net/http/httputil" | |||
| 12 | + | "net/url" | |||
| 13 | + | "strings" | |||
| 14 | + | "sync" | |||
| 15 | + | "sync/atomic" | |||
| 16 | + | "testing" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "github.com/stretchr/testify/assert" | |||
| 20 | + | "github.com/stretchr/testify/require" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | const testIdle = 300 * time.Millisecond | |||
| 24 | + | ||||
| 25 | + | func newIdleServer(t *testing.T, handler http.Handler) *httptest.Server { | |||
| 26 | + | t.Helper() | |||
| 27 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(handler)) | |||
| 28 | + | srv.Config.ReadHeaderTimeout = 5 * time.Second | |||
| 29 | + | srv.Start() | |||
| 30 | + | t.Cleanup(srv.Close) | |||
| 31 | + | return srv | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestTransferIdleTimeout_SlowActiveDownloadCompletes(t *testing.T) { | |||
| 35 | + | const chunks = 12 // 12 x 50ms = 600ms, twice the idle timeout | |||
| 36 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 37 | + | for range chunks { | |||
| 38 | + | _, _ = w.Write([]byte("x")) | |||
| 39 | + | w.(http.Flusher).Flush() | |||
| 40 | + | time.Sleep(50 * time.Millisecond) | |||
| 41 | + | } | |||
| 42 | + | })) | |||
| 43 | + | ||||
| 44 | + | start := time.Now() | |||
| 45 | + | resp, err := http.Get(srv.URL) | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | defer resp.Body.Close() | |||
| 48 | + | body, err := io.ReadAll(resp.Body) | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | assert.Len(t, body, chunks) | |||
| 51 | + | assert.Greater(t, time.Since(start), testIdle, "transfer must outlast the idle timeout") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestTransferIdleTimeout_SilentResponseIsCut(t *testing.T) { | |||
| 55 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 56 | + | _, _ = w.Write([]byte("start")) | |||
| 57 | + | w.(http.Flusher).Flush() | |||
| 58 | + | time.Sleep(3 * testIdle) | |||
| 59 | + | _, _ = w.Write([]byte("late")) | |||
| 60 | + | w.(http.Flusher).Flush() | |||
| 61 | + | })) | |||
| 62 | + | ||||
| 63 | + | resp, err := http.Get(srv.URL) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | defer resp.Body.Close() | |||
| 66 | + | body, err := io.ReadAll(resp.Body) | |||
| 67 | + | require.Error(t, err, "connection must be cut after the idle timeout") | |||
| 68 | + | assert.Equal(t, "start", string(body)) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestTransferIdleTimeout_SilentHandlerBeforeFirstByteIsCut(t *testing.T) { | |||
| 72 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 73 | + | time.Sleep(3 * testIdle) | |||
| 74 | + | _, _ = w.Write([]byte("late")) | |||
| 75 | + | })) | |||
| 76 | + | ||||
| 77 | + | resp, err := http.Get(srv.URL) | |||
| 78 | + | if err == nil { | |||
| 79 | + | defer resp.Body.Close() | |||
| 80 | + | _, err = io.ReadAll(resp.Body) | |||
| 81 | + | } | |||
| 82 | + | require.Error(t, err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestTransferIdleTimeout_SlowActiveUploadCompletes(t *testing.T) { | |||
| 86 | + | const chunks = 10 // 10 x 60ms = 600ms | |||
| 87 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 88 | + | n, err := io.Copy(io.Discard, r.Body) | |||
| 89 | + | if err != nil { | |||
| 90 | + | http.Error(w, err.Error(), http.StatusBadRequest) | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | _, _ = w.Write([]byte(strings.Repeat("o", int(n)))) | |||
| 94 | + | })) | |||
| 95 | + | ||||
| 96 | + | pr, pw := io.Pipe() | |||
| 97 | + | go func() { | |||
| 98 | + | for range chunks { | |||
| 99 | + | _, _ = pw.Write([]byte("u")) | |||
| 100 | + | time.Sleep(60 * time.Millisecond) | |||
| 101 | + | } | |||
| 102 | + | _ = pw.Close() | |||
| 103 | + | }() | |||
| 104 | + | resp, err := http.Post(srv.URL, "application/octet-stream", pr) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | defer resp.Body.Close() | |||
| 107 | + | body, err := io.ReadAll(resp.Body) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 110 | + | assert.Len(t, body, chunks) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestTransferIdleTimeout_StalledUploadIsCut(t *testing.T) { | |||
| 114 | + | var readErr atomic.Value | |||
| 115 | + | done := make(chan struct{}) | |||
| 116 | + | srv := newIdleServer(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { | |||
| 117 | + | defer close(done) | |||
| 118 | + | _, err := io.Copy(io.Discard, r.Body) | |||
| 119 | + | if err != nil { | |||
| 120 | + | readErr.Store(err) | |||
| 121 | + | } | |||
| 122 | + | })) | |||
| 123 | + | ||||
| 124 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |||
| 125 | + | require.NoError(t, err) | |||
| 126 | + | defer conn.Close() | |||
| 127 | + | _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nabc")) | |||
| 128 | + | require.NoError(t, err) | |||
| 129 | + | ||||
| 130 | + | select { | |||
| 131 | + | case <-done: | |||
| 132 | + | case <-time.After(5 * testIdle): | |||
| 133 | + | t.Fatal("stalled upload was not cut") | |||
| 134 | + | } | |||
| 135 | + | assert.NotNil(t, readErr.Load(), "body read must fail after the idle timeout") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestTransferIdleTimeout_PreservesFlusherAndUnwrap(t *testing.T) { | |||
| 139 | + | var isFlusher, unwrapped bool | |||
| 140 | + | h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 141 | + | _, isFlusher = w.(http.Flusher) | |||
| 142 | + | _, unwrapped = w.(interface{ Unwrap() http.ResponseWriter }) | |||
| 143 | + | })) | |||
| 144 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) | |||
| 145 | + | assert.True(t, isFlusher) | |||
| 146 | + | assert.True(t, unwrapped) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestTransferIdleTimeout_HijackStillWorks(t *testing.T) { | |||
| 150 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 151 | + | hj, ok := w.(http.Hijacker) | |||
| 152 | + | if !ok { | |||
| 153 | + | http.Error(w, "no hijacker", http.StatusInternalServerError) | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | conn, rw, err := hj.Hijack() | |||
| 157 | + | if err != nil { | |||
| 158 | + | return | |||
| 159 | + | } | |||
| 160 | + | defer conn.Close() | |||
| 161 | + | _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n") | |||
| 162 | + | _ = rw.Flush() | |||
| 163 | + | // Stay open longer than the idle timeout: hijacked streams are exempt. | |||
| 164 | + | time.Sleep(2 * testIdle) | |||
| 165 | + | _, _ = rw.WriteString("hello") | |||
| 166 | + | _ = rw.Flush() | |||
| 167 | + | })) | |||
| 168 | + | ||||
| 169 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |||
| 170 | + | require.NoError(t, err) | |||
| 171 | + | defer conn.Close() | |||
| 172 | + | _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n")) | |||
| 173 | + | require.NoError(t, err) | |||
| 174 | + | ||||
| 175 | + | br := bufio.NewReader(conn) | |||
| 176 | + | resp, err := http.ReadResponse(br, nil) | |||
| 177 | + | require.NoError(t, err) | |||
| 178 | + | assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) | |||
| 179 | + | buf := make([]byte, 5) | |||
| 180 | + | _, err = io.ReadFull(br, buf) | |||
| 181 | + | require.NoError(t, err) | |||
| 182 | + | assert.Equal(t, "hello", string(buf)) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestTransferIdleTimeout_ReverseProxyFlushesStream(t *testing.T) { | |||
| 186 | + | // Flush propagation through a wrapped writer, as used by httputil.ReverseProxy. | |||
| 187 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 188 | + | rc := http.NewResponseController(w) | |||
| 189 | + | for range 8 { | |||
| 190 | + | _, _ = w.Write([]byte(".")) | |||
| 191 | + | if err := rc.Flush(); err != nil { | |||
| 192 | + | return | |||
| 193 | + | } | |||
| 194 | + | time.Sleep(50 * time.Millisecond) | |||
| 195 | + | } | |||
| 196 | + | })) | |||
| 197 | + | resp, err := http.Get(srv.URL) | |||
| 198 | + | require.NoError(t, err) | |||
| 199 | + | defer resp.Body.Close() | |||
| 200 | + | body, err := io.ReadAll(resp.Body) | |||
| 201 | + | require.NoError(t, err) | |||
| 202 | + | assert.Len(t, body, 8) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestTransferIdleTimeout_DisabledWhenNonPositive(t *testing.T) { | |||
| 206 | + | called := false | |||
| 207 | + | inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) | |||
| 208 | + | h := TransferIdleTimeout(0)(inner) | |||
| 209 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) | |||
| 210 | + | assert.True(t, called) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func TestTransferIdleTimeout_HTTP2(t *testing.T) { | |||
| 214 | + | const chunks = 12 | |||
| 215 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 216 | + | for range chunks { | |||
| 217 | + | _, _ = w.Write([]byte("x")) | |||
| 218 | + | w.(http.Flusher).Flush() | |||
| 219 | + | time.Sleep(50 * time.Millisecond) | |||
| 220 | + | } | |||
| 221 | + | }))) | |||
| 222 | + | srv.EnableHTTP2 = true | |||
| 223 | + | srv.StartTLS() | |||
| 224 | + | defer srv.Close() | |||
| 225 | + | ||||
| 226 | + | client := srv.Client() | |||
| 227 | + | resp, err := client.Get(srv.URL) | |||
| 228 | + | require.NoError(t, err) | |||
| 229 | + | defer resp.Body.Close() | |||
| 230 | + | assert.Equal(t, 2, resp.ProtoMajor) | |||
| 231 | + | body, err := io.ReadAll(resp.Body) | |||
| 232 | + | require.NoError(t, err) | |||
| 233 | + | assert.Len(t, body, chunks) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // pipeListener serves connections created with dial over net.Pipe, which has no | |||
| 237 | + | // kernel buffering: a write completes only when the peer reads it. | |||
| 238 | + | type pipeListener struct { | |||
| 239 | + | conns chan net.Conn | |||
| 240 | + | done chan struct{} | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func newPipeListener() *pipeListener { | |||
| 244 | + | return &pipeListener{conns: make(chan net.Conn), done: make(chan struct{})} | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func (l *pipeListener) Accept() (net.Conn, error) { | |||
| 248 | + | select { | |||
| 249 | + | case c := <-l.conns: | |||
| 250 | + | return c, nil | |||
| 251 | + | case <-l.done: | |||
| 252 | + | return nil, net.ErrClosed | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func (l *pipeListener) Close() error { | |||
| 257 | + | select { | |||
| 258 | + | case <-l.done: | |||
| 259 | + | default: | |||
| 260 | + | close(l.done) | |||
| 261 | + | } | |||
| 262 | + | return nil | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func (l *pipeListener) Addr() net.Addr { return pipeAddr{} } | |||
| 266 | + | ||||
| 267 | + | func (l *pipeListener) dial() net.Conn { | |||
| 268 | + | client, server := net.Pipe() | |||
| 269 | + | l.conns <- server | |||
| 270 | + | return client | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | type pipeAddr struct{} | |||
| 274 | + | ||||
| 275 | + | func (pipeAddr) Network() string { return "pipe" } | |||
| 276 | + | func (pipeAddr) String() string { return "pipe" } | |||
| 277 | + | ||||
| 278 | + | // slowReadConn reads at most 4 KiB every pause, like a congested client. | |||
| 279 | + | type slowReadConn struct { | |||
| 280 | + | net.Conn | |||
| 281 | + | pause time.Duration | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func (c *slowReadConn) Read(p []byte) (int, error) { | |||
| 285 | + | time.Sleep(c.pause) | |||
| 286 | + | return c.Conn.Read(p[:min(len(p), 4096)]) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func servePipe(t *testing.T, handler http.Handler) *pipeListener { | |||
| 290 | + | t.Helper() | |||
| 291 | + | ln := newPipeListener() | |||
| 292 | + | srv := &http.Server{Handler: TransferIdleTimeout(testIdle)(handler), ReadHeaderTimeout: 5 * time.Second} | |||
| 293 | + | go func() { _ = srv.Serve(ln) }() | |||
| 294 | + | t.Cleanup(func() { _ = srv.Close() }) | |||
| 295 | + | return ln | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestTransferIdleTimeout_BackpressuredButReadingClientIsNotCut(t *testing.T) { | |||
| 299 | + | const size = 64 * 1024 | |||
| 300 | + | ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 301 | + | _, _ = w.Write([]byte(strings.Repeat("d", size))) | |||
| 302 | + | })) | |||
| 303 | + | client := &http.Client{Transport: &http.Transport{ | |||
| 304 | + | DisableKeepAlives: true, | |||
| 305 | + | DialContext: func(context.Context, string, string) (net.Conn, error) { | |||
| 306 | + | return &slowReadConn{Conn: ln.dial(), pause: 25 * time.Millisecond}, nil | |||
| 307 | + | }, | |||
| 308 | + | }} | |||
| 309 | + | ||||
| 310 | + | start := time.Now() | |||
| 311 | + | resp, err := client.Get("http://pipe/") | |||
| 312 | + | require.NoError(t, err) | |||
| 313 | + | defer resp.Body.Close() | |||
| 314 | + | body, err := io.ReadAll(resp.Body) | |||
| 315 | + | require.NoError(t, err, "a client that keeps reading must not be cut") | |||
| 316 | + | assert.Len(t, body, size) | |||
| 317 | + | assert.Greater(t, time.Since(start), testIdle, "the transfer must outlast the idle timeout") | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func TestTransferIdleTimeout_NonReadingClientUnblocksWrite(t *testing.T) { | |||
| 321 | + | writeErr := make(chan error, 1) | |||
| 322 | + | ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 323 | + | _, err := w.Write([]byte(strings.Repeat("d", 1<<20))) | |||
| 324 | + | writeErr <- err | |||
| 325 | + | })) | |||
| 326 | + | conn := ln.dial() | |||
| 327 | + | defer conn.Close() | |||
| 328 | + | _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n")) | |||
| 329 | + | require.NoError(t, err) | |||
| 330 | + | ||||
| 331 | + | select { | |||
| 332 | + | case err := <-writeErr: | |||
| 333 | + | require.Error(t, err, "write to a client that never reads must fail") | |||
| 334 | + | case <-time.After(10 * testIdle): | |||
| 335 | + | t.Fatal("blocked write was not unblocked by the inactivity timeout") | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func TestTransferIdleTimeout_SilentHandlerContextCanceledAndConnectionClosed(t *testing.T) { | |||
| 340 | + | ctxDone := make(chan struct{}) | |||
| 341 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 342 | + | _, _ = w.Write([]byte("start")) | |||
| 343 | + | w.(http.Flusher).Flush() | |||
| 344 | + | <-r.Context().Done() // a ReverseProxy would abort its upstream request here | |||
| 345 | + | close(ctxDone) | |||
| 346 | + | })) | |||
| 347 | + | ||||
| 348 | + | client := &http.Client{Timeout: 10 * time.Second} | |||
| 349 | + | begin := time.Now() | |||
| 350 | + | resp, err := client.Get(srv.URL) | |||
| 351 | + | require.NoError(t, err) | |||
| 352 | + | defer resp.Body.Close() | |||
| 353 | + | body, err := io.ReadAll(resp.Body) | |||
| 354 | + | require.Error(t, err, "the client connection must be closed, not completed cleanly") | |||
| 355 | + | assert.Equal(t, "start", string(body)) | |||
| 356 | + | assert.Less(t, time.Since(begin), 5*time.Second) | |||
| 357 | + | ||||
| 358 | + | select { | |||
| 359 | + | case <-ctxDone: | |||
| 360 | + | case <-time.After(5 * time.Second): | |||
| 361 | + | t.Fatal("request context was not canceled") | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func TestTransferIdleTimeout_ProgressKeepsContextAlive(t *testing.T) { | |||
| 366 | + | var canceled atomic.Bool | |||
| 367 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 368 | + | for range 12 { | |||
| 369 | + | _, _ = w.Write([]byte("x")) | |||
| 370 | + | w.(http.Flusher).Flush() | |||
| 371 | + | select { | |||
| 372 | + | case <-r.Context().Done(): | |||
| 373 | + | canceled.Store(true) | |||
| 374 | + | return | |||
| 375 | + | case <-time.After(50 * time.Millisecond): | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | })) | |||
| 379 | + | resp, err := http.Get(srv.URL) | |||
| 380 | + | require.NoError(t, err) | |||
| 381 | + | defer resp.Body.Close() | |||
| 382 | + | body, err := io.ReadAll(resp.Body) | |||
| 383 | + | require.NoError(t, err) | |||
| 384 | + | assert.Len(t, body, 12) | |||
| 385 | + | assert.False(t, canceled.Load()) | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func TestTransferIdleTimeout_BodyUseAfterHandlerReturnedIsSafe(t *testing.T) { | |||
| 389 | + | var body io.ReadCloser | |||
| 390 | + | h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { | |||
| 391 | + | body = r.Body | |||
| 392 | + | })) | |||
| 393 | + | req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("payload")) | |||
| 394 | + | h.ServeHTTP(httptest.NewRecorder(), req) | |||
| 395 | + | ||||
| 396 | + | // Like http.Transport closing the outbound body after the handler returned. | |||
| 397 | + | require.NoError(t, body.Close()) | |||
| 398 | + | require.NoError(t, body.Close(), "Close must be idempotent") | |||
| 399 | + | _, _ = body.Read(make([]byte, 4)) | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // TestTransferIdleTimeout_HTTP2ReverseProxyEarlyResponseDuringUpload covers the | |||
| 403 | + | // transport closing the outbound request body after the handler returned, when | |||
| 404 | + | // the upstream answers early (413) while the client is still uploading. Run | |||
| 405 | + | // with -race. | |||
| 406 | + | func TestTransferIdleTimeout_HTTP2ReverseProxyEarlyResponseDuringUpload(t *testing.T) { | |||
| 407 | + | upstream := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 408 | + | http.Error(w, "too large", http.StatusRequestEntityTooLarge) | |||
| 409 | + | })) | |||
| 410 | + | upstream.EnableHTTP2 = true | |||
| 411 | + | upstream.StartTLS() | |||
| 412 | + | defer upstream.Close() | |||
| 413 | + | ||||
| 414 | + | target, err := url.Parse(upstream.URL) | |||
| 415 | + | require.NoError(t, err) | |||
| 416 | + | proxy := httputil.NewSingleHostReverseProxy(target) | |||
| 417 | + | proxy.Transport = &http.Transport{ | |||
| 418 | + | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test upstream | |||
| 419 | + | ForceAttemptHTTP2: true, | |||
| 420 | + | } | |||
| 421 | + | front := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(proxy)) | |||
| 422 | + | front.EnableHTTP2 = true | |||
| 423 | + | front.StartTLS() | |||
| 424 | + | defer front.Close() | |||
| 425 | + | ||||
| 426 | + | var wg sync.WaitGroup | |||
| 427 | + | for range 8 { | |||
| 428 | + | wg.Add(1) | |||
| 429 | + | go func() { | |||
| 430 | + | defer wg.Done() | |||
| 431 | + | for range 10 { | |||
| 432 | + | uploadUntilEarlyResponse(t, front) | |||
| 433 | + | } | |||
| 434 | + | }() | |||
| 435 | + | } | |||
| 436 | + | wg.Wait() | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | func uploadUntilEarlyResponse(t *testing.T, front *httptest.Server) { | |||
| 440 | + | t.Helper() | |||
| 441 | + | pr, pw := io.Pipe() | |||
| 442 | + | writerDone := make(chan struct{}) | |||
| 443 | + | go func() { | |||
| 444 | + | defer close(writerDone) | |||
| 445 | + | chunk := []byte(strings.Repeat("u", 4096)) | |||
| 446 | + | for range 200 { | |||
| 447 | + | if _, err := pw.Write(chunk); err != nil { | |||
| 448 | + | return | |||
| 449 | + | } | |||
| 450 | + | time.Sleep(time.Millisecond) | |||
| 451 | + | } | |||
| 452 | + | _ = pw.Close() | |||
| 453 | + | }() | |||
| 454 | + | ||||
| 455 | + | defer func() { | |||
| 456 | + | _ = pr.CloseWithError(io.ErrClosedPipe) | |||
| 457 | + | <-writerDone | |||
| 458 | + | }() | |||
| 459 | + | ||||
| 460 | + | req, err := http.NewRequest(http.MethodPost, front.URL, pr) | |||
| 461 | + | if !assert.NoError(t, err) { | |||
| 462 | + | return | |||
| 463 | + | } | |||
| 464 | + | resp, err := front.Client().Do(req) | |||
| 465 | + | if !assert.NoError(t, err, "the early 413 must reach the client") { | |||
| 466 | + | return | |||
| 467 | + | } | |||
| 468 | + | defer resp.Body.Close() | |||
| 469 | + | assert.Equal(t, 2, resp.ProtoMajor) | |||
| 470 | + | assert.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) | |||
| 471 | + | _, _ = io.Copy(io.Discard, resp.Body) | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | // TestTransferIdleTimeout_HTTP2BodyUsedAfterHandlerReturned uses the request | |||
| 475 | + | // body after net/http's HTTP/2 server finished the handler (it drops its | |||
| 476 | + | // response writer state then), as http.Transport does when it closes an | |||
| 477 | + | // outbound body asynchronously. It must neither panic nor race. | |||
| 478 | + | func TestTransferIdleTimeout_HTTP2BodyUsedAfterHandlerReturned(t *testing.T) { | |||
| 479 | + | bodyCh := make(chan io.ReadCloser, 1) | |||
| 480 | + | returned := make(chan struct{}) | |||
| 481 | + | inner := TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 482 | + | bodyCh <- r.Body | |||
| 483 | + | _, _ = w.Write([]byte("early")) | |||
| 484 | + | })) | |||
| 485 | + | srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 486 | + | inner.ServeHTTP(w, r) | |||
| 487 | + | close(returned) | |||
| 488 | + | })) | |||
| 489 | + | srv.EnableHTTP2 = true | |||
| 490 | + | srv.StartTLS() | |||
| 491 | + | defer srv.Close() | |||
| 492 | + | ||||
| 493 | + | pr, pw := io.Pipe() | |||
| 494 | + | defer pw.Close() | |||
| 495 | + | req, err := http.NewRequest(http.MethodPost, srv.URL, pr) | |||
| 496 | + | require.NoError(t, err) | |||
| 497 | + | go func() { _, _ = pw.Write([]byte("partial")) }() | |||
| 498 | + | resp, err := srv.Client().Do(req) | |||
| 499 | + | require.NoError(t, err) | |||
| 500 | + | _, _ = io.Copy(io.Discard, resp.Body) | |||
| 501 | + | _ = resp.Body.Close() | |||
| 502 | + | ||||
| 503 | + | body := <-bodyCh | |||
| 504 | + | <-returned | |||
| 505 | + | time.Sleep(100 * time.Millisecond) // let the server loop process handlerDone | |||
| 506 | + | ||||
| 507 | + | _, _ = body.Read(make([]byte, 8)) | |||
| 508 | + | require.NoError(t, body.Close()) | |||
| 509 | + | require.NoError(t, body.Close()) | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | // trackedPipeServer serves net.Pipe connections and reports when the server | |||
| 513 | + | // side closes one, so tests can assert a stalled connection is released. | |||
| 514 | + | func trackedPipeServer(t *testing.T, handler http.Handler) (*pipeListener, <-chan struct{}) { | |||
| 515 | + | t.Helper() | |||
| 516 | + | closed := make(chan struct{}) | |||
| 517 | + | var once sync.Once | |||
| 518 | + | ln := newPipeListener() | |||
| 519 | + | srv := &http.Server{ | |||
| 520 | + | Handler: TransferIdleTimeout(testIdle)(handler), | |||
| 521 | + | ReadHeaderTimeout: 5 * time.Second, | |||
| 522 | + | ConnState: func(_ net.Conn, state http.ConnState) { | |||
| 523 | + | if state == http.StateClosed { | |||
| 524 | + | once.Do(func() { close(closed) }) | |||
| 525 | + | } | |||
| 526 | + | }, | |||
| 527 | + | } | |||
| 528 | + | go func() { _ = srv.Serve(ln) }() | |||
| 529 | + | t.Cleanup(func() { _ = srv.Close() }) | |||
| 530 | + | return ln, closed | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | func requireClosed(t *testing.T, closed <-chan struct{}, what string) { | |||
| 534 | + | t.Helper() | |||
| 535 | + | select { | |||
| 536 | + | case <-closed: | |||
| 537 | + | case <-time.After(20 * testIdle): | |||
| 538 | + | t.Fatalf("server connection still held: %s", what) | |||
| 539 | + | } | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | // The handler returns with a small unflushed response; net/http's final flush | |||
| 543 | + | // then blocks on a client that never reads (net.Pipe has no buffering). | |||
| 544 | + | func TestTransferIdleTimeout_FinalFlushToNonReadingClientIsBounded(t *testing.T) { | |||
| 545 | + | ln, closed := trackedPipeServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 546 | + | _, _ = w.Write([]byte("small")) | |||
| 547 | + | })) | |||
| 548 | + | conn := ln.dial() | |||
| 549 | + | defer conn.Close() | |||
| 550 | + | _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n")) | |||
| 551 | + | require.NoError(t, err) | |||
| 552 | + | ||||
| 553 | + | requireClosed(t, closed, "final response flush to a non-reading client") | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | // The client declares a body it never sends and the handler answers without | |||
| 557 | + | // reading it; net/http then tries to drain the unread body. | |||
| 558 | + | func TestTransferIdleTimeout_UnreadDeclaredBodyDrainIsBounded(t *testing.T) { | |||
| 559 | + | ln, closed := trackedPipeServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 560 | + | http.Error(w, "unauthorized", http.StatusUnauthorized) | |||
| 561 | + | })) | |||
| 562 | + | conn := ln.dial() | |||
| 563 | + | defer conn.Close() | |||
| 564 | + | go func() { _, _ = io.Copy(io.Discard, conn) }() // read the response, then stay silent | |||
| 565 | + | _, err := conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n")) | |||
| 566 | + | require.NoError(t, err) | |||
| 567 | + | ||||
| 568 | + | requireClosed(t, closed, "drain of an unread declared request body") | |||
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | func TestTransferIdleTimeout_UnreadDeclaredBodyDrainIsBoundedOverTCP(t *testing.T) { | |||
| 572 | + | closed := make(chan struct{}) | |||
| 573 | + | var once sync.Once | |||
| 574 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 575 | + | http.Error(w, "unauthorized", http.StatusUnauthorized) | |||
| 576 | + | }))) | |||
| 577 | + | srv.Config.ConnState = func(_ net.Conn, state http.ConnState) { | |||
| 578 | + | if state == http.StateClosed { | |||
| 579 | + | once.Do(func() { close(closed) }) | |||
| 580 | + | } | |||
| 581 | + | } | |||
| 582 | + | srv.Start() | |||
| 583 | + | defer srv.Close() | |||
| 584 | + | ||||
| 585 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |||
| 586 | + | require.NoError(t, err) | |||
| 587 | + | defer conn.Close() | |||
| 588 | + | _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n")) | |||
| 589 | + | require.NoError(t, err) | |||
| 590 | + | ||||
| 591 | + | requireClosed(t, closed, "drain of an unread declared request body over TCP") | |||
| 592 | + | } | |||
| 593 | + | ||||
| 594 | + | // Leftover deadlines armed at handler return must not leak into the next | |||
| 595 | + | // keep-alive request on the same connection. | |||
| 596 | + | func TestTransferIdleTimeout_KeepAliveNextRequestNotAffectedByLeftoverDeadlines(t *testing.T) { | |||
| 597 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 598 | + | _, _ = io.Copy(io.Discard, r.Body) | |||
| 599 | + | _, _ = w.Write([]byte("ok")) | |||
| 600 | + | }))) | |||
| 601 | + | srv.Config.IdleTimeout = 10 * time.Second | |||
| 602 | + | srv.Start() | |||
| 603 | + | defer srv.Close() | |||
| 604 | + | ||||
| 605 | + | client := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 1}} | |||
| 606 | + | for i := range 3 { | |||
| 607 | + | resp, err := client.Post(srv.URL, "text/plain", strings.NewReader("body")) | |||
| 608 | + | require.NoError(t, err, "request %d", i) | |||
| 609 | + | _, err = io.ReadAll(resp.Body) | |||
| 610 | + | require.NoError(t, err) | |||
| 611 | + | _ = resp.Body.Close() | |||
| 612 | + | time.Sleep(2 * testIdle) // outlive the leftover deadline while the connection idles | |||
| 613 | + | } | |||
| 614 | + | } | |||
| 615 | + | ||||
| 616 | + | // Hijack flushes pending headers; it must not hold the timer's mutex while it | |||
| 617 | + | // blocks on a client that does not read. | |||
| 618 | + | func TestTransferIdleTimeout_HijackBlockedOnNonReadingClientIsBounded(t *testing.T) { | |||
| 619 | + | hijackErr := make(chan error, 1) | |||
| 620 | + | ln := servePipe(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 621 | + | w.WriteHeader(http.StatusOK) | |||
| 622 | + | conn, _, err := w.(http.Hijacker).Hijack() | |||
| 623 | + | if conn != nil { | |||
| 624 | + | _ = conn.Close() | |||
| 625 | + | } | |||
| 626 | + | hijackErr <- err | |||
| 627 | + | })) | |||
| 628 | + | conn := ln.dial() | |||
| 629 | + | defer conn.Close() | |||
| 630 | + | _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n")) | |||
| 631 | + | require.NoError(t, err) | |||
| 632 | + | ||||
| 633 | + | select { | |||
| 634 | + | case <-hijackErr: | |||
| 635 | + | case <-time.After(20 * testIdle): | |||
| 636 | + | t.Fatal("Hijack blocked on a non-reading client was not bounded") | |||
| 637 | + | } | |||
| 638 | + | } | |||
A internal/adapters/in/traffic/http_server.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package traffic | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // httpOptions returns the effective HTTP server options of the current traffic | |
| 11 | + | // snapshot, falling back to the defaults before the first graph is applied. | |
| 12 | + | func (m *Manager) httpOptions() domain.HTTPOptions { | |
| 13 | + | graph := m.snapshot.Load() | |
| 14 | + | if graph == nil { | |
| 15 | + | return domain.DefaultHTTPOptions() | |
| 16 | + | } | |
| 17 | + | return graph.Options.HTTP.WithDefaults() | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // newEntryPointHTTPServer builds an HTTP server for a traffic entrypoint. | |
| 21 | + | // ReadTimeout and WriteTimeout stay zero: whole-request deadlines would cut | |
| 22 | + | // long downloads and uploads that are still moving data. The handler is | |
| 23 | + | // wrapped with the inactivity timeout instead, which renews the read and write | |
| 24 | + | // deadlines as data flows. | |
| 25 | + | func newEntryPointHTTPServer(handler http.Handler, options domain.HTTPOptions) *http.Server { | |
| 26 | + | return &http.Server{ | |
| 27 | + | Handler: middleware.TransferIdleTimeout(options.TransferIdleTimeout)(handler), | |
| 28 | + | ReadHeaderTimeout: options.ReadHeaderTimeout, | |
| 29 | + | IdleTimeout: options.IdleTimeout, | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package traffic | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // httpOptions returns the effective HTTP server options of the current traffic | |||
| 11 | + | // snapshot, falling back to the defaults before the first graph is applied. | |||
| 12 | + | func (m *Manager) httpOptions() domain.HTTPOptions { | |||
| 13 | + | graph := m.snapshot.Load() | |||
| 14 | + | if graph == nil { | |||
| 15 | + | return domain.DefaultHTTPOptions() | |||
| 16 | + | } | |||
| 17 | + | return graph.Options.HTTP.WithDefaults() | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // newEntryPointHTTPServer builds an HTTP server for a traffic entrypoint. | |||
| 21 | + | // ReadTimeout and WriteTimeout stay zero: whole-request deadlines would cut | |||
| 22 | + | // long downloads and uploads that are still moving data. The handler is | |||
| 23 | + | // wrapped with the inactivity timeout instead, which renews the read and write | |||
| 24 | + | // deadlines as data flows. | |||
| 25 | + | func newEntryPointHTTPServer(handler http.Handler, options domain.HTTPOptions) *http.Server { | |||
| 26 | + | return &http.Server{ | |||
| 27 | + | Handler: middleware.TransferIdleTimeout(options.TransferIdleTimeout)(handler), | |||
| 28 | + | ReadHeaderTimeout: options.ReadHeaderTimeout, | |||
| 29 | + | IdleTimeout: options.IdleTimeout, | |||
| 30 | + | } | |||
| 31 | + | } | |||
A internal/adapters/in/traffic/http_server_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package traffic | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "net/http" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestNewEntryPointHTTPServerUsesConfiguredTimeouts(t *testing.T) { | |
| 18 | + | server := newEntryPointHTTPServer(http.NotFoundHandler(), domain.HTTPOptions{ | |
| 19 | + | ReadHeaderTimeout: 3 * time.Second, | |
| 20 | + | IdleTimeout: 7 * time.Second, | |
| 21 | + | TransferIdleTimeout: 9 * time.Second, | |
| 22 | + | }) | |
| 23 | + | ||
| 24 | + | assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout) | |
| 25 | + | assert.Equal(t, 7*time.Second, server.IdleTimeout) | |
| 26 | + | assert.Zero(t, server.ReadTimeout, "whole-request read timeout must be replaced by the inactivity deadline") | |
| 27 | + | assert.Zero(t, server.WriteTimeout, "whole-response write timeout must be replaced by the inactivity deadline") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestManagerHTTPOptionsDefaultsWithoutSnapshot(t *testing.T) { | |
| 31 | + | assert.Equal(t, domain.DefaultHTTPOptions(), NewManager().httpOptions()) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestEntryPointServersReceiveConfiguredHTTPOptions(t *testing.T) { | |
| 35 | + | manager := NewManager() | |
| 36 | + | graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil) | |
| 37 | + | graph.Options.HTTP = domain.HTTPOptions{ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 7 * time.Second, TransferIdleTimeout: 9 * time.Second} | |
| 38 | + | manager.SetSmartTCPHTTPServer("edge", http.NotFoundHandler(), nil) | |
| 39 | + | manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com")) | |
| 40 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |
| 41 | + | defer shutdownManager(t, manager) | |
| 42 | + | ||
| 43 | + | runtime := tcpRuntimeForTest(t, manager, "edge") | |
| 44 | + | require.NotNil(t, runtime) | |
| 45 | + | runtime.mu.Lock() | |
| 46 | + | plain, secure := runtime.smartHTTPServer, runtime.smartTLSServer | |
| 47 | + | runtime.mu.Unlock() | |
| 48 | + | for name, server := range map[string]*http.Server{"plain": plain, "tls": secure} { | |
| 49 | + | require.NotNil(t, server, name) | |
| 50 | + | assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout, name) | |
| 51 | + | assert.Equal(t, 7*time.Second, server.IdleTimeout, name) | |
| 52 | + | assert.Zero(t, server.ReadTimeout, name) | |
| 53 | + | assert.Zero(t, server.WriteTimeout, name) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestTLSMuxServerReceivesConfiguredHTTPOptions(t *testing.T) { | |
| 58 | + | manager := NewManager() | |
| 59 | + | graph := domain.TrafficGraph{ | |
| 60 | + | Options: domain.TrafficOptions{HTTP: domain.HTTPOptions{ReadHeaderTimeout: 4 * time.Second, IdleTimeout: 8 * time.Second, TransferIdleTimeout: time.Minute}}, | |
| 61 | + | EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}}, | |
| 62 | + | } | |
| 63 | + | manager.SetTLSHTTPServer("websecure", http.NotFoundHandler(), testTLSConfig(t, "app.example.com")) | |
| 64 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |
| 65 | + | defer shutdownManager(t, manager) | |
| 66 | + | ||
| 67 | + | runtime := tcpRuntimeForTest(t, manager, "websecure") | |
| 68 | + | require.NotNil(t, runtime) | |
| 69 | + | runtime.mu.Lock() | |
| 70 | + | server := runtime.tlsHTTPServer | |
| 71 | + | runtime.mu.Unlock() | |
| 72 | + | require.NotNil(t, server) | |
| 73 | + | assert.Equal(t, 4*time.Second, server.ReadHeaderTimeout) | |
| 74 | + | assert.Equal(t, 8*time.Second, server.IdleTimeout) | |
| 75 | + | assert.Zero(t, server.ReadTimeout) | |
| 76 | + | assert.Zero(t, server.WriteTimeout) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestSmartTCPStreamingResponseOutlivesTransferIdleTimeout(t *testing.T) { | |
| 80 | + | const idle = 300 * time.Millisecond | |
| 81 | + | manager := NewManager() | |
| 82 | + | graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil) | |
| 83 | + | graph.Options.HTTP = domain.HTTPOptions{TransferIdleTimeout: idle} | |
| 84 | + | manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 85 | + | silent := r.URL.Path == "/silent" | |
| 86 | + | for i := 0; i < 12; i++ { | |
| 87 | + | if _, err := w.Write([]byte("x")); err != nil { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | w.(http.Flusher).Flush() | |
| 91 | + | if silent { | |
| 92 | + | // Stay silent until the inactivity timeout cancels the request. | |
| 93 | + | <-r.Context().Done() | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | select { | |
| 97 | + | case <-r.Context().Done(): | |
| 98 | + | return | |
| 99 | + | case <-time.After(50 * time.Millisecond): | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | }), nil) | |
| 103 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |
| 104 | + | defer shutdownManager(t, manager) | |
| 105 | + | ||
| 106 | + | get := func(path string) (int, error) { | |
| 107 | + | conn := dialTCP(t, graph.EntryPoints[0].Address) | |
| 108 | + | defer conn.Close() | |
| 109 | + | _, err := conn.Write([]byte("GET " + path + " HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n")) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | resp, err := http.ReadResponse(bufio.NewReader(conn), nil) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | defer resp.Body.Close() | |
| 114 | + | body, err := io.ReadAll(resp.Body) | |
| 115 | + | return len(body), err | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | n, err := get("/active") | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | assert.Equal(t, 12, n, "active transfer longer than the idle timeout must complete") | |
| 121 | + | ||
| 122 | + | n, err = get("/silent") | |
| 123 | + | require.Error(t, err, "silent transfer must be cut") | |
| 124 | + | assert.Less(t, n, 12) | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package traffic | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "net/http" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestNewEntryPointHTTPServerUsesConfiguredTimeouts(t *testing.T) { | |||
| 18 | + | server := newEntryPointHTTPServer(http.NotFoundHandler(), domain.HTTPOptions{ | |||
| 19 | + | ReadHeaderTimeout: 3 * time.Second, | |||
| 20 | + | IdleTimeout: 7 * time.Second, | |||
| 21 | + | TransferIdleTimeout: 9 * time.Second, | |||
| 22 | + | }) | |||
| 23 | + | ||||
| 24 | + | assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout) | |||
| 25 | + | assert.Equal(t, 7*time.Second, server.IdleTimeout) | |||
| 26 | + | assert.Zero(t, server.ReadTimeout, "whole-request read timeout must be replaced by the inactivity deadline") | |||
| 27 | + | assert.Zero(t, server.WriteTimeout, "whole-response write timeout must be replaced by the inactivity deadline") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestManagerHTTPOptionsDefaultsWithoutSnapshot(t *testing.T) { | |||
| 31 | + | assert.Equal(t, domain.DefaultHTTPOptions(), NewManager().httpOptions()) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestEntryPointServersReceiveConfiguredHTTPOptions(t *testing.T) { | |||
| 35 | + | manager := NewManager() | |||
| 36 | + | graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil) | |||
| 37 | + | graph.Options.HTTP = domain.HTTPOptions{ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 7 * time.Second, TransferIdleTimeout: 9 * time.Second} | |||
| 38 | + | manager.SetSmartTCPHTTPServer("edge", http.NotFoundHandler(), nil) | |||
| 39 | + | manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com")) | |||
| 40 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |||
| 41 | + | defer shutdownManager(t, manager) | |||
| 42 | + | ||||
| 43 | + | runtime := tcpRuntimeForTest(t, manager, "edge") | |||
| 44 | + | require.NotNil(t, runtime) | |||
| 45 | + | runtime.mu.Lock() | |||
| 46 | + | plain, secure := runtime.smartHTTPServer, runtime.smartTLSServer | |||
| 47 | + | runtime.mu.Unlock() | |||
| 48 | + | for name, server := range map[string]*http.Server{"plain": plain, "tls": secure} { | |||
| 49 | + | require.NotNil(t, server, name) | |||
| 50 | + | assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout, name) | |||
| 51 | + | assert.Equal(t, 7*time.Second, server.IdleTimeout, name) | |||
| 52 | + | assert.Zero(t, server.ReadTimeout, name) | |||
| 53 | + | assert.Zero(t, server.WriteTimeout, name) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestTLSMuxServerReceivesConfiguredHTTPOptions(t *testing.T) { | |||
| 58 | + | manager := NewManager() | |||
| 59 | + | graph := domain.TrafficGraph{ | |||
| 60 | + | Options: domain.TrafficOptions{HTTP: domain.HTTPOptions{ReadHeaderTimeout: 4 * time.Second, IdleTimeout: 8 * time.Second, TransferIdleTimeout: time.Minute}}, | |||
| 61 | + | EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}}, | |||
| 62 | + | } | |||
| 63 | + | manager.SetTLSHTTPServer("websecure", http.NotFoundHandler(), testTLSConfig(t, "app.example.com")) | |||
| 64 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |||
| 65 | + | defer shutdownManager(t, manager) | |||
| 66 | + | ||||
| 67 | + | runtime := tcpRuntimeForTest(t, manager, "websecure") | |||
| 68 | + | require.NotNil(t, runtime) | |||
| 69 | + | runtime.mu.Lock() | |||
| 70 | + | server := runtime.tlsHTTPServer | |||
| 71 | + | runtime.mu.Unlock() | |||
| 72 | + | require.NotNil(t, server) | |||
| 73 | + | assert.Equal(t, 4*time.Second, server.ReadHeaderTimeout) | |||
| 74 | + | assert.Equal(t, 8*time.Second, server.IdleTimeout) | |||
| 75 | + | assert.Zero(t, server.ReadTimeout) | |||
| 76 | + | assert.Zero(t, server.WriteTimeout) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestSmartTCPStreamingResponseOutlivesTransferIdleTimeout(t *testing.T) { | |||
| 80 | + | const idle = 300 * time.Millisecond | |||
| 81 | + | manager := NewManager() | |||
| 82 | + | graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil) | |||
| 83 | + | graph.Options.HTTP = domain.HTTPOptions{TransferIdleTimeout: idle} | |||
| 84 | + | manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 85 | + | silent := r.URL.Path == "/silent" | |||
| 86 | + | for i := 0; i < 12; i++ { | |||
| 87 | + | if _, err := w.Write([]byte("x")); err != nil { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | w.(http.Flusher).Flush() | |||
| 91 | + | if silent { | |||
| 92 | + | // Stay silent until the inactivity timeout cancels the request. | |||
| 93 | + | <-r.Context().Done() | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | select { | |||
| 97 | + | case <-r.Context().Done(): | |||
| 98 | + | return | |||
| 99 | + | case <-time.After(50 * time.Millisecond): | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | }), nil) | |||
| 103 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |||
| 104 | + | defer shutdownManager(t, manager) | |||
| 105 | + | ||||
| 106 | + | get := func(path string) (int, error) { | |||
| 107 | + | conn := dialTCP(t, graph.EntryPoints[0].Address) | |||
| 108 | + | defer conn.Close() | |||
| 109 | + | _, err := conn.Write([]byte("GET " + path + " HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n")) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | resp, err := http.ReadResponse(bufio.NewReader(conn), nil) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | defer resp.Body.Close() | |||
| 114 | + | body, err := io.ReadAll(resp.Body) | |||
| 115 | + | return len(body), err | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | n, err := get("/active") | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | assert.Equal(t, 12, n, "active transfer longer than the idle timeout must complete") | |||
| 121 | + | ||||
| 122 | + | n, err = get("/silent") | |||
| 123 | + | require.Error(t, err, "silent transfer must be cut") | |||
| 124 | + | assert.Less(t, n, 12) | |||
| 125 | + | } | |||
M internal/adapters/in/traffic/smart_tcp.go +4 -2
| @@ -151,7 +151,8 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi | |||
| 151 | 151 | return | |
| 152 | 152 | } | |
| 153 | 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 | 156 | done := make(chan struct{}) | |
| 156 | 157 | r.mu.Lock() | |
| 157 | 158 | oldListener := r.smartHTTPListener | |
| @@ -182,7 +183,8 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin | |||
| 182 | 183 | return | |
| 183 | 184 | } | |
| 184 | 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 | 188 | done := make(chan struct{}) | |
| 187 | 189 | r.mu.Lock() | |
| 188 | 190 | oldListener := r.smartTLSListener | |
| @@ -151,7 +151,8 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi | |||||
| 151 | return | 151 | 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.smartHTTPListener | 158 | oldListener := r.smartHTTPListener | ||
| @@ -182,7 +183,8 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin | |||||
| 182 | return | 183 | 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.smartTLSListener | 190 | oldListener := r.smartTLSListener | ||
M internal/adapters/in/traffic/tcp.go +2 -8
| @@ -169,14 +169,8 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c | |||
| 169 | 169 | return | |
| 170 | 170 | } | |
| 171 | 171 | 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() | |
| 180 | 174 | done := make(chan struct{}) | |
| 181 | 175 | ||
| 182 | 176 | r.mu.Lock() | |
| @@ -169,14 +169,8 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c | |||||
| 169 | return | 169 | 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 ( | |||
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | 7 | "strings" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zerowrap" | |
| 10 | 11 | "github.com/spf13/viper" | |
| @@ -37,6 +38,14 @@ type Config struct { | |||
| 37 | 38 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | |
| 38 | 39 | ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` | |
| 39 | 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 | 49 | } `mapstructure:"server"` | |
| 41 | 50 | ||
| 42 | 51 | Apps struct { | |
| @@ -225,6 +234,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) { | |||
| 225 | 234 | if err := validateRetiredAppConfig(v); err != nil { | |
| 226 | 235 | return nil, Config{}, err | |
| 227 | 236 | } | |
| 237 | + | if err := validateHTTPTimeouts(cfg); err != nil { | |
| 238 | + | return nil, Config{}, err | |
| 239 | + | } | |
| 228 | 240 | if _, err := buildAppMountPolicies(cfg); err != nil { | |
| 229 | 241 | return nil, Config{}, err | |
| 230 | 242 | } | |
| @@ -439,6 +451,22 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 439 | 451 | v.SetDefault("server.proxy_allowed_ips", []string{}) | |
| 440 | 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 | 470 | ConfigureViper(v, configPath) | |
| 443 | 471 | ||
| 444 | 472 | if err := v.ReadInConfig(); err != nil { | |
| @@ -453,3 +481,54 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 453 | 481 | ||
| 454 | 482 | 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 | + | } | |
| @@ -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{}, err | 235 | 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{}, err | 241 | 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 nil | 482 | 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 ( | |||
| 8 | 8 | "net/http" | |
| 9 | 9 | "strconv" | |
| 10 | 10 | "strings" | |
| 11 | - | "time" | |
| 12 | 11 | ||
| 13 | 12 | "github.com/bnema/zerowrap" | |
| 14 | 13 | ||
| @@ -353,17 +352,21 @@ func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} { | |||
| 353 | 352 | 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 | 360 | port := strconv.Itoa(cfg.Server.RegistryPort) | |
| 358 | 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 | 363 | internalReady := closedReadyChannel() | |
| 361 | 364 | if !needsInternalRegistryListener(cfg.Server.RegistryListenAddr) { | |
| 362 | - | return externalSrv, externalReady, nil, internalReady | |
| 365 | + | return externalSrv, externalReady, nil, internalReady, nil | |
| 363 | 366 | } | |
| 364 | 367 | 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 | |
| 367 | 370 | } | |
| 368 | 371 | ||
| 369 | 372 | func closedReadyChannel() <-chan struct{} { | |
| @@ -384,18 +387,19 @@ func needsInternalRegistryListener(listenAddr string) bool { | |||
| 384 | 387 | // that closes once the listening socket is bound. This lets callers wait for the | |
| 385 | 388 | // port to be ready before taking actions that depend on it (e.g. auto-start | |
| 386 | 389 | // 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() | |
| 389 | 393 | ready := make(chan struct{}) | |
| 390 | 394 | ||
| 391 | 395 | server := &http.Server{ | |
| 392 | 396 | Addr: addr, | |
| 393 | 397 | Handler: handler, | |
| 394 | 398 | 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, | |
| 399 | 403 | MaxHeaderBytes: 1 << 20, | |
| 400 | 404 | } | |
| 401 | 405 | ||
| @@ -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 names | 352 | 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, internalReady | 365 | + | 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, internalReady | 369 | + | 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 the | 387 | // 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-start | 388 | // 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 for | 389 | // 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) { | |||
| 195 | 195 | // The deferred signal.Stop calls ensure signal handlers are properly | |
| 196 | 196 | // cleaned up before program exit, preventing signal handler leaks. | |
| 197 | 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 cyclomatic | |
| 199 | - | // complexity within the project limit of 15. | |
| 200 | - | accessWriterConcrete, err := initAccessLog(cfg, log) | |
| 198 | + | accessWriter, closeAccessLog, err := openAccessLog(svc, cfg, log) | |
| 201 | 199 | if err != nil { | |
| 202 | 200 | return err | |
| 203 | 201 | } | |
| 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() | |
| 214 | 203 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 215 | 204 | defer cancel() | |
| 216 | 205 | ||
| @@ -227,7 +216,10 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 227 | 216 | svc.httpProxyHandler = httpProxyHandler | |
| 228 | 217 | 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 | 224 | // closeStarted shuts down any servers that were started before an error occurred, | |
| 233 | 225 | // preventing leaked listeners during partial startup failures. | |
| @@ -383,3 +375,22 @@ func waitForServerReady(ctx context.Context, ready <-chan struct{}, errChan <-ch | |||
| 383 | 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 | + | } | |
| @@ -195,22 +195,11 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||||
| 195 | // The deferred signal.Stop calls ensure signal handlers are properly | 195 | // 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 cyclomatic | 198 | + | 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 err | 200 | 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 = httpProxyHandler | 216 | svc.httpProxyHandler = httpProxyHandler | ||
| 228 | svc.httpsProxyHandler = httpsProxyHandler | 217 | 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 { | |||
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | type TrafficOptions struct { | |
| 45 | - | TCP TCPOptions | |
| 46 | - | UDP UDPOptions | |
| 45 | + | TCP TCPOptions | |
| 46 | + | UDP UDPOptions | |
| 47 | + | HTTP HTTPOptions | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | type TCPOptions struct { | |
| @@ -230,6 +231,9 @@ func (g TrafficGraph) Validate() error { | |||
| 230 | 231 | } | |
| 231 | 232 | ||
| 232 | 233 | func validateTrafficOptions(options TrafficOptions) error { | |
| 234 | + | if err := options.HTTP.Validate(); err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 233 | 237 | if err := validateOptionalPositiveDuration(options.TCP.DialTimeout, "traffic.tcp.dial_timeout"); err != nil { | |
| 234 | 238 | return err | |
| 235 | 239 | } | |
| @@ -42,8 +42,9 @@ type TrafficGraph struct { | |||||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | type TrafficOptions struct { | 44 | type TrafficOptions struct { | ||
| 45 | - | TCP TCPOptions | 45 | + | TCP TCPOptions |
| 46 | - | UDP UDPOptions | 46 | + | 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 err | 238 | return err | ||
| 235 | } | 239 | } | ||
M internal/usecase/traffic/builder.go +34 -1
| @@ -38,6 +38,15 @@ type Config struct { | |||
| 38 | 38 | TCP TCPConfig `mapstructure:"tcp"` | |
| 39 | 39 | UDP UDPConfig `mapstructure:"udp"` | |
| 40 | 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 | 52 | type TCPConfig struct { | |
| @@ -476,7 +485,31 @@ func buildOptions(cfg Config) (domain.TrafficOptions, error) { | |||
| 476 | 485 | if err != nil { | |
| 477 | 486 | 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}}, 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 | |
| 480 | 513 | } | |
| 481 | 514 | ||
| 482 | 515 | 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{}, err | 486 | 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}}, 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 | |||
| 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) { | |||
| 173 | 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 | 191 | func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) { | |
| 177 | 192 | t.Run("duplicate service name", func(t *testing.T) { | |
| 178 | 193 | _, 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{ | ||