Repository: mccutchen/go-httpbin (go)

Issue:
feat: allow ReadTimeout for http.Server to be configured
It should be possible for users to override these settings when deploying the public mccutchn/go-httpbin Docker image without having to build and deploy their own customized image:
https://github.com/mccutchen/go-httpbin/blob/ce8d74780610f33e85a7da0a9871b6c75131a8e2/httpbin/cmd/cmd.go#L30-L33

Those settings are then used here:
https://github.com/mccutchen/go-httpbin/blob/ce8d74780610f33e85a7da0a9871b6c75131a8e2/httpbin/cmd/cmd.go#L96-L102

---

<details>
<summary>Original issue description</summary>
When uploading files, it usually takes a long time. The default 5s time is not enough. Adjust to configurable items for greater flexibility.
</details>

Pull request description:
feat: allow configuration of underlying `http.Server`
Fixes #194, which pointed out that we should allow configuring additional relevant options on the underlying [http.Server](https://pkg.go.dev/net/http#Server) without requiring users to build and push their own images.

Patch:
diff --git a/README.md b/README.md
index e1d668b..ac5f235 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,7 @@ variables (or a combination of the two):
 | Argument| Env var | Documentation | Default |
 | - | - | - | - |
 | `-allowed-redirect-domains` | `ALLOWED_REDIRECT_DOMAINS` | Comma-separated list of domains the /redirect-to endpoint will allow | |
+| `-exclude-headers` | `EXCLUDE_HEADERS` | Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard suffix matching. For example: `"foo,bar,x-fc-*"` | - |
 | `-host` | `HOST` | Host to listen on | "0.0.0.0" |
 | `-https-cert-file` | `HTTPS_CERT_FILE` | HTTPS Server certificate file | |
 | `-https-key-file` | `HTTPS_KEY_FILE` | HTTPS Server private key file | |
@@ -100,8 +101,10 @@ variables (or a combination of the two):
 | `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s |
 | `-port` | `PORT` | Port to listen on | 8080 |
 | `-prefix` | `PREFIX` | Prefix of path to listen on (must start with slash and does not end with slash) | |
+| `-srv-max-header-bytes` | `SRV_MAX_HEADER_BYTES` | Value to use for the http.Server's MaxHeaderBytes option | 16384 |
+| `-srv-read-header-timeout` | `SRV_READ_HEADER_TIMEOUT` | Value to use for the http.Server's ReadHeaderTimeout option | 1s |
+| `-srv-read-timeout` | `SRV_READ_TIMEOUT` | Value to use for the http.Server's ReadTimeout option | 5s |
 | `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
-| `-exclude-headers` | `EXCLUDE_HEADERS` | Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard suffix matching. For example: `"foo,bar,x-fc-*"` | - |
 
 **Notes:**
 - Command line arguments take precedence over environment variables.
diff --git a/httpbin/cmd/cmd.go b/httpbin/cmd/cmd.go
index cdda27b..9990b98 100644
--- a/httpbin/cmd/cmd.go
+++ b/httpbin/cmd/cmd.go
@@ -27,10 +27,10 @@ const (
 	defaultLogFormat  = "text"
 	defaultEnvPrefix  = "HTTPBIN_ENV_"
 
-	// Reasonable defaults for our http server
-	srvReadTimeout       = 5 * time.Second
-	srvReadHeaderTimeout = 1 * time.Second
-	srvMaxHeaderBytes    = 16 * 1024 // 16kb
+	// Reasonable defaults for the underlying http.Server
+	defaultSrvReadTimeout       = 5 * time.Second
+	defaultSrvReadHeaderTimeout = 1 * time.Second
+	defaultSrvMaxHeaderBytes    = 16 * 1024 // 16kb
 )
 
 // Main is the main entrypoint for the go-httpbin binary. See loadConfig() for
@@ -96,9 +96,9 @@ func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []
 	srv := &http.Server{
 		Addr:              net.JoinHostPort(cfg.ListenHost, strconv.Itoa(cfg.ListenPort)),
 		Handler:           app.Handler(),
-		MaxHeaderBytes:    srvMaxHeaderBytes,
-		ReadHeaderTimeout: srvReadHeaderTimeout,
-		ReadTimeout:       srvReadTimeout,
+		MaxHeaderBytes:    cfg.SrvMaxHeaderBytes,
+		ReadHeaderTimeout: cfg.SrvReadHeaderTimeout,
+		ReadTimeout:       cfg.SrvReadTimeout,
 	}
 
 	if err := listenAndServeGracefully(srv, cfg, logger); err != nil {
@@ -124,6 +124,9 @@ type config struct {
 	TLSCertFile            string
 	TLSKeyFile             string
 	LogFormat              string
+	SrvMaxHeaderBytes      int
+	SrvReadHeaderTimeout   time.Duration
+	SrvReadTimeout         time.Duration
 
 	// temporary placeholders for arguments that need extra processing
 	rawAllowedRedirectDomains string
@@ -162,6 +165,9 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
 	fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file")
 	fs.StringVar(&cfg.ExcludeHeaders, "exclude-headers", "", "Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard matching.")
 	fs.StringVar(&cfg.LogFormat, "log-format", defaultLogFormat, "Log format (text or json)")
+	fs.IntVar(&cfg.SrvMaxHeaderBytes, "srv-max-header-bytes", defaultSrvMaxHeaderBytes, "Value to use for the http.Server's MaxHeaderBytes option")
+	fs.DurationVar(&cfg.SrvReadHeaderTimeout, "srv-read-header-timeout", defaultSrvReadHeaderTimeout, "Value to use for the http.Server's ReadHeaderTimeout option")
+	fs.DurationVar(&cfg.SrvReadTimeout, "srv-read-timeout", defaultSrvReadTimeout, "Value to use for the http.Server's ReadTimeout option")
 
 	// in order to fully control error output whether CLI arguments or env vars
 	// are used to configure the app, we need to take control away from the
@@ -275,6 +281,26 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
 		}
 	}
 
+	// set the http.Server options
+	if cfg.SrvMaxHeaderBytes == defaultSrvMaxHeaderBytes && getEnvVal("SRV_MAX_HEADER_BYTES") != "" {
+		cfg.SrvMaxHeaderBytes, err = strconv.Atoi(getEnvVal("SRV_MAX_HEADER_BYTES"))
+		if err != nil {
+			return nil, configErr("invalid value %#v for env var SRV_MAX_HEADER_BYTES: parse error", getEnvVal("SRV_MAX_HEADER_BYTES"))
+		}
+	}
+	if cfg.SrvReadHeaderTimeout == defaultSrvReadHeaderTimeout && getEnvVal("SRV_READ_HEADER_TIMEOUT") != "" {
+		cfg.SrvReadHeaderTimeout, err = time.ParseDuration(getEnvVal("SRV_READ_HEADER_TIMEOUT"))
+		if err != nil {
+			return nil, configErr("invalid value %#v for env var SRV_READ_HEADER_TIMEOUT: parse error", getEnvVal("SRV_READ_HEADER_TIMEOUT"))
+		}
+	}
+	if cfg.SrvReadTimeout == defaultSrvReadTimeout && getEnvVal("SRV_READ_TIMEOUT") != "" {
+		cfg.SrvReadTimeout, err = time.ParseDuration(getEnvVal("SRV_READ_TIMEOUT"))
+		if err != nil {
+			return nil, configErr("invalid value %#v for env var SRV_READ_TIMEOUT: parse error", getEnvVal("SRV_READ_TIMEOUT"))
+		}
+	}
+
 	// reset temporary fields to their zero values
 	cfg.rawAllowedRedirectDomains = ""
 	cfg.rawUseRealHostname = false


Test patch:
diff --git a/httpbin/cmd/cmd_test.go b/httpbin/cmd/cmd_test.go
index ecf7065..5f0d121 100644
--- a/httpbin/cmd/cmd_test.go
+++ b/httpbin/cmd/cmd_test.go
@@ -40,6 +40,12 @@ const usage = `Usage of go-httpbin:
     	Port to listen on (default 8080)
   -prefix string
     	Path prefix (empty or start with slash and does not end with slash)
+  -srv-max-header-bytes int
+    	Value to use for the http.Server's MaxHeaderBytes option (default 16384)
+  -srv-read-header-timeout duration
+    	Value to use for the http.Server's ReadHeaderTimeout option (default 1s)
+  -srv-read-timeout duration
+    	Value to use for the http.Server's ReadTimeout option (default 5s)
   -use-real-hostname
     	Expose value of os.Hostname() in the /hostname endpoint instead of dummy value
 `
@@ -52,6 +58,9 @@ func TestLoadConfig(t *testing.T) {
 		return testDefaultRealHostname, nil
 	}
 
+	defaultCfg, err := loadConfig(nil, func(string) string { return "" }, func() []string { return nil }, getHostnameDefault)
+	assert.NilError(t, err)
+
 	testCases := map[string]struct {
 		args        []string
 		env         map[string]string
@@ -62,11 +71,14 @@ func TestLoadConfig(t *testing.T) {
 	}{
 		"defaults": {
 			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
+				ListenHost:           defaultListenHost,
+				ListenPort:           defaultListenPort,
+				MaxBodySize:          httpbin.DefaultMaxBodySize,
+				MaxDuration:          httpbin.DefaultMaxDuration,
+				LogFormat:            defaultLogFormat,
+				SrvMaxHeaderBytes:    defaultSrvMaxHeaderBytes,
+				SrvReadHeaderTimeout: defaultSrvReadHeaderTimeout,
+				SrvReadTimeout:       defaultSrvReadTimeout,
 			},
 		},
 		"-h": {
@@ -80,15 +92,8 @@ func TestLoadConfig(t *testing.T) {
 
 		// env
 		"ok env with empty variables": {
-			env: map[string]string{},
-			wantCfg: &config{
-				Env:         nil,
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			env:     map[string]string{},
+			wantCfg: defaultCfg,
 		},
 		"ok env with recognized variables": {
 			env: map[string]string{
@@ -96,29 +101,17 @@ func TestLoadConfig(t *testing.T) {
 				fmt.Sprintf("%s%sBAR", defaultEnvPrefix, defaultEnvPrefix): "bar",
 				fmt.Sprintf("%s123", defaultEnvPrefix):                     "123",
 			},
-			wantCfg: &config{
+			wantCfg: mergedConfig(defaultCfg, &config{
 				Env: map[string]string{
 					fmt.Sprintf("%sFOO", defaultEnvPrefix):                     "foo",
 					fmt.Sprintf("%s%sBAR", defaultEnvPrefix, defaultEnvPrefix): "bar",
 					fmt.Sprintf("%s123", defaultEnvPrefix):                     "123",
 				},
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 		"ok env with unrecognized variables": {
-			env: map[string]string{"HTTPBIN_FOO": "foo", "BAR": "bar"},
-			wantCfg: &config{
-				Env:         nil,
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			env:     map[string]string{"HTTPBIN_FOO": "foo", "BAR": "bar"},
+			wantCfg: defaultCfg,
 		},
 
 		// max body size
@@ -132,34 +125,22 @@ func TestLoadConfig(t *testing.T) {
 		},
 		"ok -max-body-size": {
 			args: []string{"-max-body-size", "99"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				MaxBodySize: 99,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 		"ok MAX_BODY_SIZE": {
 			env: map[string]string{"MAX_BODY_SIZE": "9999"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				MaxBodySize: 9999,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 		"ok max body size CLI takes precedence over env": {
 			args: []string{"-max-body-size", "1234"},
 			env:  map[string]string{"MAX_BODY_SIZE": "5678"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				MaxBodySize: 1234,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 
 		// max duration
@@ -173,67 +154,43 @@ func TestLoadConfig(t *testing.T) {
 		},
 		"ok -max-duration": {
 			args: []string{"-max-duration", "99s"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				MaxDuration: 99 * time.Second,
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 		"ok MAX_DURATION": {
 			env: map[string]string{"MAX_DURATION": "9999s"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				MaxDuration: 9999 * time.Second,
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 		"ok max duration size CLI takes precedence over env": {
 			args: []string{"-max-duration", "1234s"},
 			env:  map[string]string{"MAX_DURATION": "5678s"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				MaxDuration: 1234 * time.Second,
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 
 		// host
 		"ok -host": {
 			args: []string{"-host", "192.0.0.1"},
-			wantCfg: &config{
-				ListenHost:  "192.0.0.1",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				ListenHost: "192.0.0.1",
+			}),
 		},
 		"ok HOST": {
 			env: map[string]string{"HOST": "192.0.0.2"},
-			wantCfg: &config{
-				ListenHost:  "192.0.0.2",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				ListenHost: "192.0.0.2",
+			}),
 		},
 		"ok host cli takes precedence over end": {
 			args: []string{"-host", "99.99.99.99"},
 			env:  map[string]string{"HOST": "11.11.11.11"},
-			wantCfg: &config{
-				ListenHost:  "99.99.99.99",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				ListenHost: "99.99.99.99",
+			}),
 		},
 
 		// port
@@ -247,34 +204,22 @@ func TestLoadConfig(t *testing.T) {
 		},
 		"ok -port": {
 			args: []string{"-port", "99"},
-			wantCfg: &config{
-				ListenHost:  defaultListenHost,
-				ListenPort:  99,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				ListenPort: 99,
+			}),
 		},
 		"ok PORT": {
 			env: map[string]string{"PORT": "9999"},
-			wantCfg: &config{
-				ListenHost:  defaultListenHost,
-				ListenPort:  9999,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				ListenPort: 9999,
+			}),
 		},
 		"ok port CLI takes precedence over env": {
 			args: []string{"-port", "1234"},
 			env:  map[string]string{"PORT": "5678"},
-			wantCfg: &config{
-				ListenHost:  defaultListenHost,
-				ListenPort:  1234,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				ListenPort: 1234,
+			}),
 		},
 
 		// prefix
@@ -289,25 +234,15 @@ func TestLoadConfig(t *testing.T) {
 		"ok -prefix takes precedence over env": {
 			args: []string{"-prefix", "/prefix1"},
 			env:  map[string]string{"PREFIX": "/prefix2"},
-			wantCfg: &config{
-				ListenHost:  defaultListenHost,
-				ListenPort:  defaultListenPort,
-				Prefix:      "/prefix1",
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				Prefix: "/prefix1",
+			}),
 		},
 		"ok PREFIX": {
 			env: map[string]string{"PREFIX": "/prefix2"},
-			wantCfg: &config{
-				ListenHost:  defaultListenHost,
-				ListenPort:  defaultListenPort,
-				Prefix:      "/prefix2",
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				Prefix: "/prefix2",
+			}),
 		},
 
 		// https cert file
@@ -324,30 +259,20 @@ func TestLoadConfig(t *testing.T) {
 				"-https-cert-file", "/tmp/test.crt",
 				"-https-key-file", "/tmp/test.key",
 			},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				TLSCertFile: "/tmp/test.crt",
 				TLSKeyFile:  "/tmp/test.key",
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 		"ok https env": {
 			env: map[string]string{
 				"HTTPS_CERT_FILE": "/tmp/test.crt",
 				"HTTPS_KEY_FILE":  "/tmp/test.key",
 			},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				TLSCertFile: "/tmp/test.crt",
 				TLSKeyFile:  "/tmp/test.key",
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 		"ok https CLI takes precedence over env": {
 			args: []string{
@@ -358,105 +283,58 @@ func TestLoadConfig(t *testing.T) {
 				"HTTPS_CERT_FILE": "/tmp/env.crt",
 				"HTTPS_KEY_FILE":  "/tmp/env.key",
 			},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				TLSCertFile: "/tmp/cli.crt",
 				TLSKeyFile:  "/tmp/cli.key",
-				LogFormat:   defaultLogFormat,
-			},
+			}),
 		},
 
 		// use-real-hostname
 		"ok -use-real-hostname": {
 			args: []string{"-use-real-hostname"},
-			wantCfg: &config{
-				ListenHost:   "0.0.0.0",
-				ListenPort:   8080,
-				MaxBodySize:  httpbin.DefaultMaxBodySize,
-				MaxDuration:  httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				RealHostname: testDefaultRealHostname,
-				LogFormat:    defaultLogFormat,
-			},
+			}),
 		},
 		"ok -use-real-hostname=1": {
 			args: []string{"-use-real-hostname", "1"},
-			wantCfg: &config{
-				ListenHost:   "0.0.0.0",
-				ListenPort:   8080,
-				MaxBodySize:  httpbin.DefaultMaxBodySize,
-				MaxDuration:  httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				RealHostname: testDefaultRealHostname,
-				LogFormat:    defaultLogFormat,
-			},
+			}),
 		},
 		"ok -use-real-hostname=true": {
 			args: []string{"-use-real-hostname", "true"},
-			wantCfg: &config{
-				ListenHost:   "0.0.0.0",
-				ListenPort:   8080,
-				MaxBodySize:  httpbin.DefaultMaxBodySize,
-				MaxDuration:  httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				RealHostname: testDefaultRealHostname,
-				LogFormat:    defaultLogFormat,
-			},
+			}),
 		},
 		// any value for the argument is interpreted as true
 		"ok -use-real-hostname=0": {
 			args: []string{"-use-real-hostname", "0"},
-			wantCfg: &config{
-				ListenHost:   "0.0.0.0",
-				ListenPort:   8080,
-				MaxBodySize:  httpbin.DefaultMaxBodySize,
-				MaxDuration:  httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				RealHostname: testDefaultRealHostname,
-				LogFormat:    defaultLogFormat,
-			},
+			}),
 		},
 		"ok USE_REAL_HOSTNAME=1": {
 			env: map[string]string{"USE_REAL_HOSTNAME": "1"},
-			wantCfg: &config{
-				ListenHost:   "0.0.0.0",
-				ListenPort:   8080,
-				MaxBodySize:  httpbin.DefaultMaxBodySize,
-				MaxDuration:  httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				RealHostname: testDefaultRealHostname,
-				LogFormat:    defaultLogFormat,
-			},
+			}),
 		},
 		"ok USE_REAL_HOSTNAME=true": {
 			env: map[string]string{"USE_REAL_HOSTNAME": "true"},
-			wantCfg: &config{
-				ListenHost:   "0.0.0.0",
-				ListenPort:   8080,
-				MaxBodySize:  httpbin.DefaultMaxBodySize,
-				MaxDuration:  httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				RealHostname: testDefaultRealHostname,
-				LogFormat:    defaultLogFormat,
-			},
+			}),
 		},
 		// case sensitive
 		"ok USE_REAL_HOSTNAME=TRUE": {
-			env: map[string]string{"USE_REAL_HOSTNAME": "TRUE"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			env:     map[string]string{"USE_REAL_HOSTNAME": "TRUE"},
+			wantCfg: defaultCfg,
 		},
 		"ok USE_REAL_HOSTNAME=false": {
-			env: map[string]string{"USE_REAL_HOSTNAME": "false"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			env:     map[string]string{"USE_REAL_HOSTNAME": "false"},
+			wantCfg: defaultCfg,
 		},
 		"err real hostname error": {
 			env:         map[string]string{"USE_REAL_HOSTNAME": "true"},
@@ -467,87 +345,135 @@ func TestLoadConfig(t *testing.T) {
 		// allowed-redirect-domains
 		"ok -allowed-redirect-domains": {
 			args: []string{"-allowed-redirect-domains", "foo,bar"},
-			wantCfg: &config{
-				ListenHost:             "0.0.0.0",
-				ListenPort:             8080,
-				MaxBodySize:            httpbin.DefaultMaxBodySize,
-				MaxDuration:            httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				AllowedRedirectDomains: []string{"foo", "bar"},
-				LogFormat:              defaultLogFormat,
-			},
+			}),
 		},
 		"ok ALLOWED_REDIRECT_DOMAINS": {
 			env: map[string]string{"ALLOWED_REDIRECT_DOMAINS": "foo,bar"},
-			wantCfg: &config{
-				ListenHost:             "0.0.0.0",
-				ListenPort:             8080,
-				MaxBodySize:            httpbin.DefaultMaxBodySize,
-				MaxDuration:            httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				AllowedRedirectDomains: []string{"foo", "bar"},
-				LogFormat:              defaultLogFormat,
-			},
+			}),
 		},
 		"ok allowed redirect domains CLI takes precedence over env": {
 			args: []string{"-allowed-redirect-domains", "foo.cli,bar.cli"},
 			env:  map[string]string{"ALLOWED_REDIRECT_DOMAINS": "foo.env,bar.env"},
-			wantCfg: &config{
-				ListenHost:             "0.0.0.0",
-				ListenPort:             8080,
-				MaxBodySize:            httpbin.DefaultMaxBodySize,
-				MaxDuration:            httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				AllowedRedirectDomains: []string{"foo.cli", "bar.cli"},
-				LogFormat:              defaultLogFormat,
-			},
+			}),
 		},
 		"ok allowed redirect domains are normalized": {
 			args: []string{"-allowed-redirect-domains", "foo, bar  ,, baz   "},
-			wantCfg: &config{
-				ListenHost:             "0.0.0.0",
-				ListenPort:             8080,
-				MaxBodySize:            httpbin.DefaultMaxBodySize,
-				MaxDuration:            httpbin.DefaultMaxDuration,
+			wantCfg: mergedConfig(defaultCfg, &config{
 				AllowedRedirectDomains: []string{"foo", "bar", "baz"},
-				LogFormat:              defaultLogFormat,
-			},
+			}),
 		},
+
+		// log-format
 		"ok use json log format": {
 			args: []string{"-log-format", "json"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   "json",
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				LogFormat: "json",
+			}),
 		},
 		"ok use text log format": {
 			args: []string{"-log-format", "text"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   "text",
-			},
-		},
-		"ok use default log format": {
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   defaultLogFormat,
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				LogFormat: "text",
+			}),
 		},
 		"ok use json log format using LOG_FORMAT env": {
 			env: map[string]string{"LOG_FORMAT": "json"},
-			wantCfg: &config{
-				ListenHost:  "0.0.0.0",
-				ListenPort:  8080,
-				MaxBodySize: httpbin.DefaultMaxBodySize,
-				MaxDuration: httpbin.DefaultMaxDuration,
-				LogFormat:   "json",
-			},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				LogFormat: "json",
+			}),
+		},
+
+		// srv-max-header-bytes
+		"invalid -srv-max-header-bytes": {
+			args:    []string{"-srv-max-header-bytes", "foo"},
+			wantErr: errors.New("invalid value \"foo\" for flag -srv-max-header-bytes: parse error"),
+		},
+		"invalid SRV_MAX_HEADER_BYTES": {
+			env:     map[string]string{"SRV_MAX_HEADER_BYTES": "foo"},
+			wantErr: errors.New("invalid value \"foo\" for env var SRV_MAX_HEADER_BYTES: parse error"),
+		},
+		"ok -srv-max-header-bytes": {
+			args: []string{"-srv-max-header-bytes", "99"},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				SrvMaxHeaderBytes: 99,
+			}),
+		},
+		"ok SRV_MAX_HEADER_BYTES": {
+			env: map[string]string{"SRV_MAX_HEADER_BYTES": "9999"},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				SrvMaxHeaderBytes: 9999,
+			}),
+		},
+		"ok srv-max-header-bytes CLI takes precedence over SRV_MAX_HEADER_BYTES env": {
+			args: []string{"-srv-max-header-bytes", "1234"},
+			env:  map[string]string{"SRV_MAX_HEADER_BYTES": "5678"},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				SrvMaxHeaderBytes: 1234,
+			}),
+		},
+
+		// srv-read-header-timeout
+		"invalid -srv-read-header-timeout": {
+			args:    []string{"-srv-read-header-timeout", "foo"},
+			wantErr: errors.New("invalid value \"foo\" for flag -srv-read-header-timeout: parse error"),
+		},
+		"invalid SRV_READ_HEADER_TIMEOUT": {
+			env:     map[string]string{"SRV_READ_HEADER_TIMEOUT": "foo"},
+			wantErr: errors.New("invalid value \"foo\" for env var SRV_READ_HEADER_TIMEOUT: parse error"),
+		},
+		"ok -srv-read-header-timeout": {
+			args: []string{"-srv-read-header-timeout", "99s"},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				SrvReadHeaderTimeout: 99 * time.Second,
+			}),
+		},
+		"ok SRV_READ_HEADER_TIMEOUT": {
+			env: map[string]string{"SRV_READ_HEADER_TIMEOUT": "9999s"},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				SrvReadHeaderTimeout: 9999 * time.Second,
+			}),
+		},
+		"ok -srv-read-header-timeout CLI takes precedence over SRV_READ_HEADER_TIMEOUT env": {
+			args: []string{"-srv-read-header-timeout", "1234s"},
+			env:  map[string]string{"SRV_READ_HEADER_TIMEOUT": "5678s"},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				SrvReadHeaderTimeout: 1234 * time.Second,
+			}),
+		},
+
+		// srv-read-timeout
+		"invalid -srv-read-timeout": {
+			args:    []string{"-srv-read-timeout", "foo"},
+			wantErr: errors.New("invalid value \"foo\" for flag -srv-read-timeout: parse error"),
+		},
+		"invalid SRV_READ_TIMEOUT": {
+			env:     map[string]string{"SRV_READ_TIMEOUT": "foo"},
+			wantErr: errors.New("invalid value \"foo\" for env var SRV_READ_TIMEOUT: parse error"),
+		},
+		"ok -srv-read-timeout": {
+			args: []string{"-srv-read-timeout", "99s"},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				SrvReadTimeout: 99 * time.Second,
+			}),
+		},
+		"ok SRV_READ_TIMEOUT": {
+			env: map[string]string{"SRV_READ_TIMEOUT": "9999s"},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				SrvReadTimeout: 9999 * time.Second,
+			}),
+		},
+		"ok -srv-read-timeout CLI takes precedence over SRV_READ_TIMEOUT env": {
+			args: []string{"-srv-read-timeout", "1234s"},
+			env:  map[string]string{"SRV_READ_TIMEOUT": "5678s"},
+			wantCfg: mergedConfig(defaultCfg, &config{
+				SrvReadTimeout: 1234 * time.Second,
+			}),
 		},
 	}
 
@@ -677,3 +603,64 @@ func environSlice(env map[string]string) []string {
 	}
 	return envStrings
 }
+
+// mergedConfig takes two config struct pointers and returns a new config where
+// non-zero values from the second config override values in the first config.
+func mergedConfig(base, override *config) *config {
+	result := &config{}
+	*result = *base
+
+	overrideVal := reflect.ValueOf(*override)
+	resultVal := reflect.ValueOf(result).Elem()
+	configType := overrideVal.Type()
+
+	for i := 0; i < configType.NumField(); i++ {
+		field := configType.Field(i)
+		fieldName := field.Name
+		overrideField := overrideVal.FieldByName(fieldName)
+		resultField := resultVal.FieldByName(fieldName)
+		switch field.Type.Kind() {
+		case reflect.Map:
+			if !overrideField.IsNil() && overrideField.Len() > 0 {
+				newMap := reflect.MakeMap(field.Type)
+				iter := overrideField.MapRange()
+				for iter.Next() {
+					newMap.SetMapIndex(iter.Key(), iter.Value())
+				}
+				resultField.Set(newMap)
+			}
+		case reflect.Slice:
+			if !overrideField.IsNil() && overrideField.Len() > 0 {
+				newSlice := reflect.MakeSlice(field.Type, overrideField.Len(), overrideField.Len())
+				reflect.Copy(newSlice, overrideField)
+				resultField.Set(newSlice)
+			}
+		case reflect.String:
+			if overrideField.String() != "" {
+				resultField.SetString(overrideField.String())
+			}
+		case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+			if overrideField.Int() != 0 {
+				resultField.SetInt(overrideField.Int())
+			}
+		case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+			if overrideField.Uint() != 0 {
+				resultField.SetUint(overrideField.Uint())
+			}
+		case reflect.Float32, reflect.Float64:
+			if overrideField.Float() != 0 {
+				resultField.SetFloat(overrideField.Float())
+			}
+		case reflect.Bool:
+			if overrideField.Bool() {
+				resultField.SetBool(overrideField.Bool())
+			}
+		case reflect.Ptr:
+			if !overrideField.IsNil() {
+				resultField.Set(overrideField)
+			}
+		}
+	}
+
+	return result
+}