🐛 drain the appsec response body so connections are reused

Fix #384.

appsecQuery never read the appsec response body. net/http only returns a
connection to the idle pool once its body has reached EOF, so closing early
discarded it: every request opened a fresh TCP connection to the appsec host and
left it in TIME_WAIT. crowdsecQuery, two functions above, does call io.ReadAll,
which is why the LAPI path in the same process pools correctly.

Measured with an httptest appsec server over 10 calls:

  empty response body      -> 1 connection   (already at EOF, reuse worked)
  non-empty response body  -> 10 connections (one per request)

The drain goes in the defer rather than before the final return, because the 500
and non-200 paths return earlier. Draining only at the end fixes 200 and leaves
the other two leaking:

  status 200 -> 1 connection    status 403 -> 10    status 500 -> 10

which is the wrong half to fix: 403 is what appsec produces for a site under
attack, and 500 is what a wedged appsec produces.

Also removes the unreachable `if err != nil { ... appsecQuery:readBody }` left
over from a version that did read the body -- err is always nil there -- and sets
MaxIdleConnsPerHost on the three transports. They each talk to a single host, so
the unset default of 2 (DefaultMaxIdleConnsPerHost) capped the pool well below
the configured MaxIdleConns of 10.

Adds Test_appsecQuery_reusesConnection, which asserts one connection for ten
calls across 200/403/500 and fails on the previous code.
This commit is contained in:
mhx
2026-09-03 08:52:51 +02:00
parent ae7481caa5
commit 60d6d57625
2 changed files with 66 additions and 10 deletions
+16 -10
View File
@@ -243,17 +243,19 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
},
httpClient: &http.Client{
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
TLSClientConfig: tlsConfig,
MaxIdleConns: 10,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
TLSClientConfig: tlsConfig,
},
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
},
httpAppsecClient: &http.Client{
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
TLSClientConfig: tlsAppsecConfig,
MaxIdleConns: 10,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
TLSClientConfig: tlsAppsecConfig,
},
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
},
@@ -278,7 +280,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
log,
bouncer.cacheClient,
&http.Client{
Transport: &http.Transport{MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second},
Transport: &http.Transport{MaxIdleConns: 10, MaxIdleConnsPerHost: 10, IdleConnTimeout: 30 * time.Second},
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
},
config.CaptchaProvider,
@@ -801,6 +803,13 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
return nil
}
defer func() {
// net/http only returns a connection to the idle pool once its body has
// been read to EOF; closing early discards it. Drain here rather than at
// the end of the function so the 500 and non-200 paths, which return
// earlier, keep their connections too.
if _, errDrain := io.Copy(io.Discard, res.Body); errDrain != nil {
bouncer.log.Debug("appsecQuery:drainBody " + errDrain.Error())
}
if err = res.Body.Close(); err != nil {
bouncer.log.Error("appsecQuery:closeBody " + err.Error())
}
@@ -816,9 +825,6 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
return fmt.Errorf("appsecQuery statusCode:%d", res.StatusCode)
}
if err != nil {
return fmt.Errorf("appsecQuery:readBody %w", err)
}
return nil
}