Skip to content

perf(middlewares): cache *http.Client on WebhookManager keyed by Timeout (#674) - #700

Merged
CybotTM merged 3 commits into
mainfrom
perf/674-webhook-client-cache
May 17, 2026
Merged

perf(middlewares): cache *http.Client on WebhookManager keyed by Timeout (#674)#700
CybotTM merged 3 commits into
mainfrom
perf/674-webhook-client-cache

Conversation

@CybotTM

@CybotTM CybotTM commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

`WebhookManager` now caches `*http.Client` per webhook `Timeout` so the underlying transport's keep-alive connection pool survives `cli.Config.rebuildAllMiddlewares` reconciles (called after every Docker label change or INI reload).

Pre-fix, `NewWebhook` built a fresh `*http.Client` and `*http.Transport` per call inside `GetMiddlewares`, so every reconcile dropped any held keep-alive connections and started fresh TCP/TLS handshakes for every job's webhooks.

Same pattern as #630 (which cached `PresetLoader`'s HTTP client for the same reason).

Closes #674.

Design

  • Cache key: `Timeout` — the only per-webhook input that varies. The shared `TransportFactory()` handles TLS/proxy posture, and the SSRF allow-list lives in the package-global security config (`SetGlobalSecurityConfig`), so neither needs to be part of the key.
  • Webhooks with the same `Timeout` share a client — the keep-alive pool serves all jobs pointed at the same endpoint family. Different timeouts get distinct clients (otherwise the operator's intent would mismatch).
  • `sync.Mutex` around the map — the manager is constructed once and `GetMiddlewares` runs from the reconcile goroutine while `Register` could in principle be called from a parallel Docker-label sync.

Backward compatibility

Standalone `NewWebhook(config, loader)` callers (tests, direct construction by third-party code) keep the legacy "fresh client per call" behavior. Only the `WebhookManager.GetMiddlewares` path goes through the cache via a new internal helper.

Refactor

Extracted a shared `newWebhookWithClient(config, loader, client)` that does all validation and accepts an optional pre-built client. `NewWebhook` becomes a thin wrapper that passes nil.

Tests (5 new, all -race clean)

  • `TestWebhookManager_SharesHTTPClientAcrossReconciles` — the headline middlewares: rebuildAllMiddlewares allocates fresh http.Client per webhook on every reconcile #674 fix pinned.
  • `TestWebhookManager_DifferentTimeoutsGetDifferentClients` — cache-key contract.
  • `TestWebhookManager_SameTimeoutAcrossWebhooksSharesClient` — same-timeout share contract.
  • `TestWebhookManager_ConcurrentCacheReadsRaceFree` — 16 concurrent `GetMiddlewares` calls across distinct names exercise the cache contention path; pins the `sync.Mutex` guarantee. Each goroutine uses its own `*WebhookConfig` to avoid the pre-existing `ApplyDefaults` race that production avoids by processing webhooks sequentially.
  • `TestNewWebhook_StandaloneStillBuildsOwnClient` — backward compatibility.

Test plan

  • `go test ./...` passes (full repo, 14 packages, ~58s)
  • `go test ./middlewares/ -race` passes
  • `golangci-lint run` clean
  • `go vet ./...` clean
  • CI green

References

  • The issue's "file and forget unless reconcile frequency goes up" caveat still applies — this isn't a regression and isn't on a hot path today. The fix is cheap and clears the foot-gun for any future feature that ups reconcile frequency.

Copilot AI review requested due to automatic review settings May 17, 2026 09:27
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests labels May 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves webhook delivery performance by caching *http.Client instances on WebhookManager keyed by webhook Timeout, allowing HTTP keep-alive pools to survive rebuildAllMiddlewares reconciles.

Changes:

  • Refactors webhook construction into a shared helper (newWebhookWithClient) that can reuse a prebuilt *http.Client.
  • Adds a WebhookManager client cache (map[time.Duration]*http.Client) with a mutex-protected getter.
  • Adds a dedicated test suite covering client sharing, timeout keying, and concurrent cache access; updates CHANGELOG.md.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
middlewares/webhook.go Refactors webhook creation and introduces manager-scoped *http.Client caching keyed by timeout.
middlewares/webhook_manager_client_cache_test.go Adds tests validating the cache contract (reuse across reconciles, timeout keying, concurrency).
CHANGELOG.md Documents the behavioral/performance change under Unreleased.

Comment thread middlewares/webhook.go Outdated
Comment thread middlewares/webhook_manager_client_cache_test.go

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces *http.Client caching in WebhookManager keyed by the webhook Timeout to allow the reuse of keep-alive connection pools across reconciles. It includes a new internal construction path for webhooks and comprehensive tests for the caching logic. Feedback was provided regarding a potential race condition where the webhooks map is accessed without the protection of the newly added mutex in the Register and GetMiddlewares methods.

Comment thread middlewares/webhook.go Outdated
CybotTM added a commit that referenced this pull request May 17, 2026
…oads

Code reviewer caught a CRITICAL gap: the original fixup added the
*http.Client cache on WebhookManager, but cli.Config.WebhookConfigs.
InitManager — called by both syncWebhookConfigs (Docker label change)
and refreshWebhookManagerOnGlobalChange (INI live-reload) — replaces
the manager wholesale and therefore discards the cache. The cache
only helped within a single rebuildAllMiddlewares loop (same manager
across multiple jobs), not across reloads. The headline #674 benefit
("survive Docker label change or INI reload") was undermined.

Fix:

- Add (*WebhookManager).AdoptClientCacheFrom(prior *WebhookManager)
  that snapshots the prior manager's httpClients and merges them into
  the new manager's cache (under both mutexes, no TOCTOU). Safe for a
  nil prior (first-init no-op).
- WebhookConfigs.InitManager now captures the prior manager reference
  before replacing it, then calls AdoptClientCacheFrom on the new
  manager. Cache survives every InitManager call.
- Security config can change between reloads, but the cached
  transports remain valid: AllowedHosts / SSRF allow-list are enforced
  at validation time (per-request), not on the transport itself.

Also addressed code reviewer "Important" items:

- ApplyDefaults was being called twice per webhook per reconcile (once
  in GetMiddlewares, once inside newWebhookWithClient). Dropped the
  duplicate outer call.
- getOrBuildClient ran BEFORE preset / variable validation, so a bad
  config minted (cached) a client. Refactored newWebhookWithClient to
  take an optional cachedClient func; it's called only after validation
  succeeds. Standalone NewWebhook keeps the legacy "fresh client per
  call" path. Manager-cached path uses (*WebhookManager).cachedClient
  as the method-value getter.

Test engineer suggestions:

- Asserted client.Transport != nil + same Transport pointer in
  SharesHTTPClientAcrossReconciles — the headline benefit is the
  shared underlying *http.Transport (keep-alive pool), not just the
  *http.Client wrapper.
- TestWebhookManager_AdoptClientCacheFrom_PreservesAcrossReload pins
  the new carry-forward contract: build mgr1, register, resolve, then
  swap to mgr2 + AdoptClientCacheFrom(mgr1), and the same *http.Client
  pointer comes back. Closes the regression-test gap for the critical
  fix.
- TestWebhookManager_AdoptClientCacheFrom_NilPriorIsNoOp pins the
  first-init contract (nil prior must not panic).
- assert.Lenf instead of assert.Len with format args.

Doc:

- TODO comment on getOrBuildClient noting the cache key must expand
  if the transport ever grows per-webhook inputs (per-cert TLS, proxy
  override, custom dialer); today `Timeout` is sufficient because
  everything else is shared via TransportFactory() and the
  package-global security config.

Deferred:

- sync.RWMutex over Mutex (code reviewer suggestion) — negligible at
  current scale; the contention test passes -race with the Mutex.
- Memory growth concern — not real in practice (operators have a
  handful of distinct timeouts; even pathological hot-reload churn
  caps at a few KB).

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
CybotTM added a commit that referenced this pull request May 17, 2026
Copilot and Gemini both (HIGH) flagged that the comment on mu claimed
to guard "races between GetMiddlewares and Register" but neither
Register/Get/GetMiddlewares actually locked the webhooks map. Real
concern; my comment was over-stated.

In current production code, the mutation paths (cli.WebhookConfigs.
InitManager from the INI loader + Docker-label sync) are sequential
per reload AND InitManager swaps the entire manager rather than
mutating the webhooks map in place — so the live manager's webhooks
map is effectively immutable from GetMiddlewares' perspective. mu
only needs to guard httpClients.

AdoptClientCacheFrom is the only cross-manager handoff and locks both
sides correctly. If a future change ever exposes Register on the hot
path concurrently with GetMiddlewares, the webhooks map will need its
own locking — comment now flags this explicitly so a future
contributor sees the contract.

No code change; doc-only.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
@CybotTM

CybotTM commented May 17, 2026

Copy link
Copy Markdown
Member Author

Multi-axis review summary (code-reviewer + test-engineer + Copilot + Gemini)

All findings applied across two fixup commits. 0 unresolved threads.

Applied — Critical

  • Cache lost on reload (code-reviewer Critical) — fixed in b461317. Added (*WebhookManager).AdoptClientCacheFrom(prior) and updated WebhookConfigs.InitManager to carry the cache forward across the manager swap. Without this, the headline middlewares: rebuildAllMiddlewares allocates fresh http.Client per webhook on every reconcile #674 benefit ("survive Docker label change or INI reload") was undermined — every reload would discard the cache. Two new regression tests (_PreservesAcrossReload, _NilPriorIsNoOp) pin the contract.

Applied — Important / High

  • Duplicate ApplyDefaults (code-reviewer Important) — dropped the outer call in GetMiddlewares; the inner call in newWebhookWithClient is the only one.
  • getOrBuildClient ran before validation (code-reviewer Important) — refactored newWebhookWithClient to take an optional cachedClient func getter; the cache is hit only after preset / variable validation succeeds, so a bad-config error never mints or caches a client.
  • mu scope comment overstated (Copilot HIGH + Gemini HIGH) — fixed in 35be317. mu only guards httpClients; the webhooks map is sequential by current production usage (InitManager swaps the entire manager rather than mutating in place). Comment now flags the contract explicitly.

Applied — Medium / Low

  • Transport != nil + same-Transport assertion (test-engineer Medium) — added to _SharesHTTPClientAcrossReconciles. The keep-alive pool is in the transport, not the client wrapper.
  • Lenf not Len with format args (code-reviewer Suggestion) — fixed.
  • TODO comment on cache key for future per-webhook transport inputs (code-reviewer) — added on getOrBuildClient.

Replied / declined

  • for i := range N claim it won't compile (Copilot) — false positive; Go 1.22+ supports integer range, repo is on Go 1.26, tests pass -race. Replied with the spec reference, declined.

Deferred (rationale on threads)

  • sync.RWMutex over Mutex (code-reviewer Suggestion) — negligible at current scale; contention test passes -race.
  • Memory growth via LRU (code-reviewer Q4) — not real in practice (operators have a handful of distinct timeouts; pathological hot-reload churn caps at ~few KB).
  • Lock the webhooks map — current production paths are sequential; comment flags the contract for future contributors.

Final stats: 7 tests (Shares + DifferentTimeouts + SameTimeoutShares + Concurrent + Standalone + AdoptPreserves + AdoptNilNoOp), CHANGELOG entry under ### Changed, all 3 inline AI threads replied + resolved.

CybotTM added 3 commits May 17, 2026 11:41
Pre-fix, NewWebhook built a fresh *http.Client and *http.Transport per
call inside WebhookManager.GetMiddlewares, so every reconcile (called
after every Docker label change or INI reload via
cli.Config.rebuildAllMiddlewares) dropped any held keep-alive
connections and started fresh TCP/TLS handshakes for every job's
webhooks.

For realistic deployments this is microseconds and unmeasurable, but
if reconciles become high-frequency (a future feature triggering
rebuildAllMiddlewares per container event in a churn-heavy environment),
tail latency on webhook delivery would rise. Same pattern as #630
(PresetLoader cached its HTTP client for the same reason).

Refactor:

- Extract a shared newWebhookWithClient(config, loader, client)
  helper that does all validation and accepts an optional pre-built
  client. NewWebhook becomes a thin wrapper that passes nil (legacy
  fresh-client-per-call behavior, preserved for tests and direct
  callers).
- Add httpClients map[time.Duration]*http.Client + sync.Mutex to
  WebhookManager. Keyed by Timeout because that's the only per-webhook
  input that varies from the shared TransportFactory()-built transport
  posture; AllowedHosts and the SSRF allow-list live in the
  package-global security config, so they're not part of the key.
- getOrBuildClient(timeout) returns the cached client or builds and
  caches a new one. Concurrent callers with the same timeout share
  the returned client.
- GetMiddlewares passes m.getOrBuildClient(config.Timeout) to the new
  helper.

Tests (5 new, all -race clean):

- TestWebhookManager_SharesHTTPClientAcrossReconciles: two
  GetMiddlewares calls in sequence return webhooks with the same
  *http.Client pointer. The headline #674 fix pinned.
- TestWebhookManager_DifferentTimeoutsGetDifferentClients: two
  webhooks with different Timeout get distinct clients (otherwise
  their *http.Client.Timeout would mismatch).
- TestWebhookManager_SameTimeoutAcrossWebhooksSharesClient: two
  distinct webhooks with the same Timeout share a client (the
  keep-alive pool serves all jobs pointed at the same endpoint
  family).
- TestWebhookManager_ConcurrentCacheReadsRaceFree: 16 concurrent
  GetMiddlewares calls across distinct webhook names exercise the
  cache contention path; pins the sync.Mutex guarantee. Each
  goroutine uses its own *WebhookConfig to avoid the pre-existing
  ApplyDefaults race that production avoids by processing webhooks
  sequentially.
- TestNewWebhook_StandaloneStillBuildsOwnClient: backward
  compatibility — direct NewWebhook callers (tests / third-party)
  still get a fresh client per call.

CHANGELOG entry under [Unreleased] ### Changed.

Closes #674.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
…oads

Code reviewer caught a CRITICAL gap: the original fixup added the
*http.Client cache on WebhookManager, but cli.Config.WebhookConfigs.
InitManager — called by both syncWebhookConfigs (Docker label change)
and refreshWebhookManagerOnGlobalChange (INI live-reload) — replaces
the manager wholesale and therefore discards the cache. The cache
only helped within a single rebuildAllMiddlewares loop (same manager
across multiple jobs), not across reloads. The headline #674 benefit
("survive Docker label change or INI reload") was undermined.

Fix:

- Add (*WebhookManager).AdoptClientCacheFrom(prior *WebhookManager)
  that snapshots the prior manager's httpClients and merges them into
  the new manager's cache (under both mutexes, no TOCTOU). Safe for a
  nil prior (first-init no-op).
- WebhookConfigs.InitManager now captures the prior manager reference
  before replacing it, then calls AdoptClientCacheFrom on the new
  manager. Cache survives every InitManager call.
- Security config can change between reloads, but the cached
  transports remain valid: AllowedHosts / SSRF allow-list are enforced
  at validation time (per-request), not on the transport itself.

Also addressed code reviewer "Important" items:

- ApplyDefaults was being called twice per webhook per reconcile (once
  in GetMiddlewares, once inside newWebhookWithClient). Dropped the
  duplicate outer call.
- getOrBuildClient ran BEFORE preset / variable validation, so a bad
  config minted (cached) a client. Refactored newWebhookWithClient to
  take an optional cachedClient func; it's called only after validation
  succeeds. Standalone NewWebhook keeps the legacy "fresh client per
  call" path. Manager-cached path uses (*WebhookManager).cachedClient
  as the method-value getter.

Test engineer suggestions:

- Asserted client.Transport != nil + same Transport pointer in
  SharesHTTPClientAcrossReconciles — the headline benefit is the
  shared underlying *http.Transport (keep-alive pool), not just the
  *http.Client wrapper.
- TestWebhookManager_AdoptClientCacheFrom_PreservesAcrossReload pins
  the new carry-forward contract: build mgr1, register, resolve, then
  swap to mgr2 + AdoptClientCacheFrom(mgr1), and the same *http.Client
  pointer comes back. Closes the regression-test gap for the critical
  fix.
- TestWebhookManager_AdoptClientCacheFrom_NilPriorIsNoOp pins the
  first-init contract (nil prior must not panic).
- assert.Lenf instead of assert.Len with format args.

Doc:

- TODO comment on getOrBuildClient noting the cache key must expand
  if the transport ever grows per-webhook inputs (per-cert TLS, proxy
  override, custom dialer); today `Timeout` is sufficient because
  everything else is shared via TransportFactory() and the
  package-global security config.

Deferred:

- sync.RWMutex over Mutex (code reviewer suggestion) — negligible at
  current scale; the contention test passes -race with the Mutex.
- Memory growth concern — not real in practice (operators have a
  handful of distinct timeouts; even pathological hot-reload churn
  caps at a few KB).

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Copilot and Gemini both (HIGH) flagged that the comment on mu claimed
to guard "races between GetMiddlewares and Register" but neither
Register/Get/GetMiddlewares actually locked the webhooks map. Real
concern; my comment was over-stated.

In current production code, the mutation paths (cli.WebhookConfigs.
InitManager from the INI loader + Docker-label sync) are sequential
per reload AND InitManager swaps the entire manager rather than
mutating the webhooks map in place — so the live manager's webhooks
map is effectively immutable from GetMiddlewares' perspective. mu
only needs to guard httpClients.

AdoptClientCacheFrom is the only cross-manager handoff and locks both
sides correctly. If a future change ever exposes Register on the hot
path concurrently with GetMiddlewares, the webhooks map will need its
own locking — comment now flags this explicitly so a future
contributor sees the contract.

No code change; doc-only.

Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
@CybotTM
CybotTM force-pushed the perf/674-webhook-client-cache branch from 35be317 to f9e358c Compare May 17, 2026 09:42
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

✅ Mutation Testing Results

Mutation Score: 100.00% (threshold: 60%)

✨ Good job! Mutation score meets the threshold.

What is mutation testing?

Mutation testing measures test quality by introducing small changes (mutations) to the code and checking if tests detect them. A higher score means better test effectiveness.

  • Killed mutants: Tests caught the mutation (good!)
  • Survived mutants: Tests missed the mutation (needs improvement)

@codecov

codecov Bot commented May 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.36842% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 88.07%. Comparing base (50a0135) to head (f9e358c).

Files with missing lines Patch % Lines
middlewares/webhook.go 97.22% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #700   +/-   ##
=======================================
  Coverage   88.06%   88.07%           
=======================================
  Files          89       89           
  Lines       11464    11497   +33     
=======================================
+ Hits        10096    10126   +30     
- Misses       1115     1118    +3     
  Partials      253      253           
Flag Coverage Δ
integration 88.05% <97.36%> (+<0.01%) ⬆️
unittests 85.29% <97.36%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@CybotTM
CybotTM added this pull request to the merge queue May 17, 2026
Merged via the queue into main with commit 44d79cb May 17, 2026
27 checks passed
@CybotTM
CybotTM deleted the perf/674-webhook-client-cache branch May 17, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

middlewares: rebuildAllMiddlewares allocates fresh http.Client per webhook on every reconcile

2 participants