Skip to content

Add short-TTL bundle cache with singleflight de-duplication - #192

Open
bdehamer wants to merge 8 commits into
mainfrom
bdehamer-bundle-cache-singleflight
Open

Add short-TTL bundle cache with singleflight de-duplication#192
bdehamer wants to merge 8 commits into
mainfrom
bdehamer-bundle-cache-singleflight

Conversation

@bdehamer

@bdehamer bdehamer commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Front the OCI bundle fetch with singleflight de-duplication and a short-TTL, digest-only in-memory cache, so that repeated and concurrent validations of the same image no longer each perform a full registry round-trip. This reduces the redundant registry load that can push attestation fetches past the admission webhook timeout.

Today Provider.Validate re-fetches bundles and re-runs verification on every request — even for an image validated moments earlier, and even when many identical requests arrive at once.

Failure modes this addresses

  • Concurrent duplicate fetches during rollouts (thundering herd). When a workload rolls out, many pods are admitted within a short window and each admission triggers a separate, uncached fetch for the same image. That concurrent load adds latency at the registry and can push fetches past the admission webhook deadline, cancelling the request mid-fetch. → Singleflight collapses concurrent fetches for the same reference into a single upstream request whose result is shared with every waiter, removing the duplicate load that causes the latency.
  • Redundant repeat validations. Gatekeeper validates the same long-running images repeatedly — at admission and during audit — re-fetching and re-verifying each time. → A short-TTL cache serves repeat validations of the same digest from memory.
  • Wasted work when the caller has already timed out. If the admission request is cancelled at the webhook deadline, a fetch bound to that request's context is cancelled with it, so nothing is cached and the retry repeats the work. → The de-duplicated fetch runs detached from the caller's cancellation, so it completes and warms the cache for the retry that follows.

What this PR does

fetcher.CachingBundleFetcher decorates the OCI fetch with two mechanisms that have different correctness properties:

  • Singleflight — keyed on the requested reference. Concurrent fetches for the same reference collapse into one shared upstream request, so a burst of identical admissions makes a single upstream call. This is the reliability fix for the herd. Coalescing shares the leader's result among the joined callers; for tag references that means sharing the leader's tag→digest resolution for the duration of one in-flight fetch — an intentional, bounded trade-off described under Design decision below.
  • Short-TTL time cache — keyed on the resolved digest (digest-only). OCI tags are mutable: a tag can be repointed to a new digest within the TTL window. Serving a tag from a persisted entry could therefore return a verification result for a digest the tag no longer points to — an admission bypass. So the time cache only ever stores and serves digest references. Tag references are still de-duplicated by singleflight but are never time-cached; a successful tag fetch only warms a digest-keyed entry that a later by-digest request can reuse. This guarantee is independent of any deployment's tag conventions or registry behavior — it is gated purely on the reference type. Note it bounds image identity, not the attestation set: the referrer set for a digest is mutable, so a cached positive result can be up to one TTL stale with respect to attestations being added or removed (removal used as revocation is not reflected until expiry). "No attestations" results are not cached, so a newly-published attestation is picked up on the next validation.
  • Detached shared fetch. A fetch that has already begun runs on context.WithoutCancel(ctx) so it completes and warms the cache even if the triggering caller's admission was cancelled mid-flight. A request that is already cancelled on arrival serves a cache hit if one exists but does not start a new fetch — so a timed-out multi-image Validate cannot spawn orphaned registry work. The inner fetcher's per-attempt timeout and retry budget still bound it.
  • Bounded size. The cache is capped; when full, storing a new entry evicts the soonest-to-expire one (dependency-free).
  • Verification is never cached. Failed fetches are not cached, and verification still runs on every call in Provider.Validate. The cache operates at the fetcher layer and returns {bundles, digest}, never a verdict — so cryptographic / trust-root revocation is re-checked on every hit. (Registry-side attestation removal is a referrer-set change and is only reflected once the entry expires — see the digest bullet above.)
  • No shared mutable state across concurrent verifications. The cache stores bundles in their immutable serialized form and hands every consumer — each cache hit and each singleflight recipient — a freshly-unmarshalled copy, so concurrent validations of the same digest never share a *bundle.Bundle instance (sigstore-go memoizes verification state on the bundle receiver, which would otherwise be an unsynchronized write).

Design decision: tag references & singleflight (reviewed, intentional)

Singleflight is keyed on the requested reference, tags included. This was raised in review as a potential tag-mutation bypass; it is a deliberate, bounded trade-off, documented here so it isn't re-litigated:

  • No verification result is ever persisted against a tag. The time cache is digest-only, so nothing keyed on a mutable tag survives beyond a single in-flight request.
  • Singleflight only coalesces concurrent in-flight requests. Joiners share the leader's tag→digest resolution for the duration of one fetch (bounded by the fetch timeout). If a tag is repointed mid-flight, a joiner can receive the leader's digest instead of re-resolving on its own.
  • That window is subsumed by the inherent admission→kubelet-pull TOCTOU. For any mutable-tag admission, the kubelet resolves the tag at pull time, independently of what the provider validated — so a tag that moves after admission already yields "validated A, running B" regardless of caching or singleflight. Coalescing only shifts the reference instant by at most one fetch duration, strictly inside that pre-existing gap; it does not change the security posture in kind.
  • Restricting singleflight to digests was considered and rejected. It would close this narrow window but reintroduce the concurrent tag→digest resolve storm that is the dominant source of the fetch timeouts this change targets — resolving each tag independently is exactly the load we are trying to collapse.
  • Strong binding (running image == validated image) is obtained by referencing images by digest, or by enforcing registry tag-immutability. That is independent of this cache and is the correct control for deployments that need it.

Configuration

  • -bundle-cache-ttl — result cache TTL. Default 60s; 0 disables the time cache (singleflight de-duplication stays active).
  • -bundle-cache-max-entries — maximum cached entries. Default 4096; 0 = unbounded. Negative values for either flag are rejected at startup.

Metrics

  • aaop_bundle_cache_hits_total, aaop_bundle_cache_misses_total — digest lookups only; an errored upstream fetch counts as a miss.
  • aaop_bundle_fetch_deduped_total — joined (non-leader) callers served by an in-flight singleflight leader.
  • aaop_bundle_cache_entries — current cache size.
  • aaop_bundle_cache_evictions_total — capacity evictions.

Scope and limitations

  • The cache is per-process and in-memory. It de-duplicates within a single provider instance; it does not coordinate across replicas or clusters, so the more provider replicas a deployment runs, the thinner requests spread and the lower the per-instance hit rate.
  • It is defense-in-depth, not a substitute for an adequate admission webhook timeoutSeconds. Where fetch latency under load is the root cause, raising the webhook timeout addresses the cancellation directly and independently of load; this PR reduces the load that produces that latency in the first place. The two are complementary.

Testing

  • go build ./...
  • go test ./... -race ✅ — digest cache hit + TTL expiry; tag references are not served from the time cache (a moved tag re-fetches); concurrent tag references are singleflighted into a single fetch; singleflight still de-duplicates when the time cache is disabled (ttl=0); a digest with no attestations is not cached (a later-published attestation is seen on the next call); a request cancelled on arrival serves a cache hit but starts no fetch; every caller receives a distinct, freshly-unmarshalled bundle set (no shared *bundle.Bundle across concurrent verifications); serialize↔deserialize round-trips losslessly; errors are not cached; an errored upstream fetch counts as a miss; a mid-flight cancelled caller still warms the cache; max-entries eviction increments the evictions metric; negative flags are rejected; distinct references; idempotent Stop.
  • golangci-lint run ./... ✅ 0 issues.

Open questions for reviewers

  • Default values-bundle-cache-ttl (60s) and -bundle-cache-max-entries (4096) are starting points; open to tuning.
  • Singleflight is always on. The tag-coalescing trade-off is covered in Design decision above; let me know if you'd nonetheless prefer it gated behind a flag.

Follow-ups

bdehamer and others added 2 commits August 19, 2026 13:58
Admission bursts for the same image — e.g. a workload rolling out across
many clusters at once — produce many concurrent validations for the same
reference. Each makes its own OCI round-trip, and that concurrent load is
what overwhelms the registry and pushes fetches past the ~5s admission
timeout (surfacing as the provider's reason=canceled). Log analysis showed
~59% of fetch failures are correlated cross-cluster same-manifest bursts.

This fronts the OCI fetch with:
- a short-TTL in-memory result cache, so repeat validations of a stable
  digest are served from memory, and
- singleflight de-duplication, so a burst of concurrent identical fetches
  collapses into a single upstream request whose result is shared with
  every waiter.

The shared fetch is detached from the triggering caller's context so it can
run to completion and warm the cache even if that caller's admission request
was already cancelled.

Configurable via -bundle-cache-ttl (default 60s; 0 disables). Failed fetches
are not cached. Adds cache hit / miss / dedupe / size metrics.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: adc26119-a4fc-4118-82b4-0601ae564252
Refine CachingBundleFetcher per the package-security#5198 design so the two
mechanisms have the correctness properties they should:

- Singleflight stays keyed on the requested reference (safe under any tag
  mutability; concurrent-only coalescing, no stale-serve window).
- The persisted TTL cache is now digest-only. OCI tags are mutable, so serving
  a tag from a persisted entry within the TTL could return a verification
  result for a digest the tag no longer points to -- an admission bypass. Tag
  refs still get singleflight; they simply never read from or write to the time
  cache. A successful tag fetch warms a digest-keyed entry so a later by-digest
  request can be served.

Also bound the cache with -bundle-cache-max-entries (default 4096, 0 =
unbounded), evicting the soonest-to-expire entry when full, and expose
aaop_bundle_cache_evictions_total.

Ref: github/package-security#5198 (comment)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@bdehamer
bdehamer force-pushed the bdehamer-bundle-cache-singleflight branch from 9043e27 to 1737d43 Compare August 19, 2026 22:09
@bdehamer
bdehamer requested a balanced review from Copilot August 19, 2026 22:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds in-memory OCI bundle caching and singleflight de-duplication to reduce registry load and admission latency.

Changes:

  • Adds a bounded, digest-keyed TTL cache and concurrent fetch coalescing.
  • Exposes cache configuration flags and Prometheus metrics.
  • Adds cache behavior, cancellation, eviction, and concurrency tests.
Show a summary per file
File Description
README.md Documents caching behavior, configuration, and metrics.
pkg/metrics/prom.go Defines cache and singleflight metrics.
pkg/fetcher/cache.go Implements caching and fetch de-duplication.
pkg/fetcher/cache_test.go Tests cache behavior and lifecycle.
go.mod Promotes x/sync to a direct dependency.
cmd/aaop/aaop.go Adds flags and integrates the caching fetcher.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Suppressed comments (1)

pkg/fetcher/cache.go:263

  • This gauge update can race with a store after the sweep unlocks, allowing the sweep's older count to overwrite the newer count. Publish the count before releasing c.mu to preserve mutation order.
	n := len(c.cache)
	c.mu.Unlock()
	metrics.BundleCacheEntries.Set(float64(n))
  • Files reviewed: 6/6 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment thread pkg/fetcher/cache.go
Comment thread cmd/aaop/aaop.go Outdated
Comment thread cmd/aaop/aaop.go
Comment thread pkg/fetcher/cache.go Outdated
Comment thread pkg/fetcher/cache.go
…fix cache metric accuracy, document tag singleflight semantics

- aaop.go: always construct CachingBundleFetcher so singleflight runs
  regardless of TTL; a non-positive -bundle-cache-ttl now disables only the
  persisted time cache (and its janitor), not the whole decorator. Startup log
  distinguishes "bundle cache enabled" from "singleflight de-duplication only".
- aaop.go: validateBundleCacheFlags rejects negative -bundle-cache-ttl and
  -bundle-cache-max-entries at startup (log.Fatal), matching
  configureBundleFetcher. 0 keeps its meaning (ttl=0 → time cache off,
  max-entries=0 → unbounded).
- cache.go: publish the BundleCacheEntries gauge while holding the lock in
  store and sweep so concurrent updates match the serialized mutation.
- cache.go: singleflight.Result.Shared is true for the leader too, so
  BundleFetchDeduped over-counted by 1 per flight. Track the leader and only
  count joiners → an N-caller flight records N-1 dedupes.
- cache.go: document that tag singleflight shares the leader's tag→digest
  resolution within the in-flight window, subsumed by the inherent
  admission→kubelet-pull TOCTOU; digest refs give a strong binding. Tag
  singleflight is kept deliberately (avoids the resolve storm).
- Tests: singleflight still coalesces with the time cache disabled (ttl=0) and
  digest fetches are not time-cached then; concurrency test asserts N-1
  dedupes; flag validation rejects negatives; janitor not started at ttl=0.
- README: ttl=0 = singleflight-only, negative flags rejected, tag-singleflight
  window rationale.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

pkg/fetcher/cache.go:109

  • An already-canceled request still reaches DoChan, whose detached closure starts a new registry fetch before the later select returns the cancellation. This is especially costly for multi-image requests: Provider.Validate continues its loop after each fetch error, so once the deadline expires every remaining key can launch orphaned work for the full retry budget. Return immediately when ctx.Err() is already set; only fetches that began while the request was live should be detached to finish.

This issue also appears on line 128 of the same file.

func (c *CachingBundleFetcher) BundleFromName(ctx context.Context, ref name.Reference, ro []remote.Option) ([]*bundle.Bundle, *v1.Hash, error) {

pkg/fetcher/cache.go:190

  • This also caches a successful “no attestations” lookup for digest inputs: DoBundleFromName returns (nil, nil, nil) when remote.Referrers is empty (bundle.go:256-260), and this branch stores that result. The subject digest is immutable, but its OCI referrer set is not; if validation races attestation publication, subsequent validations keep returning image_unsigned for the full TTL after the attestation appears. Avoid negative-caching this mutable lookup (or give it a separately documented, shorter TTL).
			case isDigest:

pkg/fetcher/cache.go:130

  • A cache hit returns the same type as an upstream fetch, so provider.go:133,151 records it in aaop_attestations_retrieved_timer and aaop_attestations_retrieved_total. The timer's Help and README define it as OCI-registry fetch latency/downloads; once hits dominate, its latency distribution no longer measures the registry and can mask the timeout signal this PR is meant to monitor. Propagate hit/upstream status (or observe registry metrics around the inner fetch) and update the metric contract accordingly.
		if e, ok := c.load(key); ok {
			metrics.BundleCacheHits.Inc()
			return e.bundles, e.hash, nil
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread cmd/aaop/aaop.go
Comment thread README.md Outdated
…celled callers, document referrer-set staleness

- cache.go: only persist positive results. A digest with no referrers resolves
  to (nil, nil, nil); caching it pinned "no attestations" for the whole TTL even
  after one was later published (the referrer set is mutable though the digest
  is immutable). Gate the store on len(b) > 0; still return the empty result to
  the caller so the next validation re-fetches and picks up a new attestation.
- cache.go: guard already-cancelled callers. A cache hit is still served (free),
  but a request whose ctx is already cancelled on arrival returns the
  cancellation without starting a DoChan fetch, so a timed-out multi-image
  Provider.Validate cannot spawn orphaned registry work during a herd. Uses
  kindFromContext, matching the mid-flight cancel path; detached warming for
  fetches that began while live is unchanged.
- README + comment: correct the "digest = always safe" framing. Digest-keying
  guarantees a moved tag cannot substitute a different image, but the referrer
  set is mutable, so a cached positive result can be up to one TTL stale wrt
  attestations added/removed (removal-as-revocation delayed to TTL);
  re-verification checks crypto against the trust root, not the referrer set;
  no-attestation results are not cached.
- Tests: an empty digest result is not cached (re-fetch reflects a later
  attestation); an already-cancelled caller with a cold cache does not call the
  inner fetcher and returns a cancellation, while a warm cache still serves the
  hit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@bdehamer

Copy link
Copy Markdown
Contributor Author

Round-2 review disposition

Thanks for the second pass. Summary of how each finding was handled (three had no inline thread to reply to):

Validation on 2d98a17: go build ./..., go test ./... -race, and golangci-lint run ./... all pass. Keeping the PR as a draft.

@bdehamer
bdehamer requested a balanced review from Copilot August 20, 2026 15:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

pkg/fetcher/cache.go:183

  • The second-chance lookup can return a cache entry after this invocation already incremented BundleCacheMisses at line 143, so that request is reported as a miss even though no upstream fetch occurs. This race is exactly why this lookup exists (another completed flight can populate the entry between the first lookup and DoChan). Carry whether the singleflight result came from this lookup and record hit versus miss only after the source is known.
		// Another caller may have populated the cache while this call queued.
		if timeCacheable {
			if e, ok := c.load(key); ok {
				return e, nil
			}
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread pkg/fetcher/cache.go Outdated
sigstore-go memoizes verification state on the *bundle.Bundle receiver
(Bundle.TlogEntries sets b.hasInclusionPromise / b.hasInclusionProof), and
PGIVerifier's verify flow calls TlogEntries. The cache previously stored and
returned the same *bundle.Bundle to every cache hit and every singleflight
recipient, so concurrent validations of the same digest -- the exact herd this
cache serves -- performed unsynchronized writes to a shared struct: a real data
race in the verification path.

Cache the immutable serialized form instead and hand each caller its own
freshly-unmarshalled bundles:
- cacheEntry now holds serialized [][]byte (marshalled bundles) instead of
  []*bundle.Bundle; hash and expires are unchanged.
- serializeBundles/deserializeBundles use Bundle.MarshalJSON/UnmarshalJSON
  (protojson under the hood). The round-trip is lossless for verification -- all
  material is in the proto and the memoized flags are recomputed per
  verification.
- Every consumer (cache-hit path, singleflight leader, and joiners) unmarshals
  its own bundle set, so no two verifications share a mutable *Bundle. Fix D
  (empty results not cached) and fix C (already-cancelled entry guard) are
  preserved.

Also fix miss-counting: the second-chance load inside the singleflight closure
could return a concurrently-populated entry after a miss was already counted at
the first lookup. Defer the hit/miss decision until the result source is known
(flightResult.fromCache): a second-chance cache hit now counts as a hit, a fresh
fetch as a miss.

Tests: a 25-goroutine concurrency test (run under -race) asserts every caller
receives a distinct *bundle.Bundle instance; a round-trip test asserts
serialize->deserialize preserves the bundle (proto.Equal). Existing tests now
use a real minimal bundle so they survive the marshal round-trip.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@bdehamer

Copy link
Copy Markdown
Contributor Author

Round-3 review disposition

  • Data race in verification (pkg/fetcher/cache.go): fixed in f21a52b. The cache stored and returned shared *bundle.Bundle objects, which concurrent validations mutate inside sigstore-go v1.3.0 — Bundle.TlogEntries() memoizes hasInclusionPromise / hasInclusionProof on the receiver, and the PGI verify path calls it. The cache now stores the immutable serialized bytes and unmarshals a private bundle set per caller (cache-hit path, singleflight leader, and every joiner), so no two verifications share a mutable *Bundle. Added a -race concurrency test asserting distinct instances, plus a serialize→deserialize round-trip test.
  • Second-chance miss mis-counting (cache.go, suppressed finding): also fixed in f21a52b. The second-chance cache lookup inside the singleflight closure could return a concurrently-populated entry after a miss had already been counted at the first lookup. The hit/miss decision is now deferred until the result source is known, so a request served by the second-chance lookup counts as a hit.

Validation on f21a52b: go build ./..., go test ./... -race, and golangci-lint run ./... all pass. Keeping the PR a draft.

@bdehamer
bdehamer requested a balanced review from Copilot August 20, 2026 16:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread pkg/fetcher/cache.go
bdehamer and others added 2 commits August 20, 2026 10:13
The res.Err early-return skipped the hit/miss accounting below it, so an
errored upstream fetch was never counted. During a registry outage that
undercounts misses and inflates the apparent hit ratio, masking the signal.
Increment BundleCacheMisses in the error branch, gated on timeCacheable to
match the success path. Cancellations are left untouched: an already-cancelled
caller and the mid-flight ctx.Done() branch are not cache outcomes and are not
counted.

Test: an errored digest fetch increments aaop_bundle_cache_misses_total, and a
cancelled caller does not.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The janitor goroutine is an internal detail of CachingBundleFetcher; noting it (and the absent defer Stop()) at the call site was confusing, since nothing here signals a janitor exists.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 815c91eb-313b-4d6c-bbe3-904ed8ad1e35
@bdehamer
bdehamer requested a balanced review from Copilot August 20, 2026 17:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

pkg/fetcher/cache_test.go:221

  • This sleep does not guarantee that all follower goroutines have entered DoChan. On a loaded CI worker, a follower can be scheduled only after inner.block is closed; because tag results are not cached, that follower starts another upstream fetch and makes both the call-count and de-duplication assertions flaky. Use deterministic synchronization (for example, a test hook/barrier that confirms every follower has joined the flight) before releasing the fake fetcher.

This issue also appears on line 277 of the same file.

	time.Sleep(50 * time.Millisecond)

pkg/fetcher/cache_test.go:277

  • The fixed sleep cannot ensure every goroutine has joined the in-flight call before the leader is released. If a goroutine is delayed past this point, ttl=0 means it cannot use a warmed cache and it will perform a second upstream call, intermittently failing the callCount() == 1 assertion. Replace the timing assumption with a deterministic barrier/test hook that confirms all followers are waiting on the flight.
	time.Sleep(50 * time.Millisecond)
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@bdehamer
bdehamer marked this pull request as ready for review August 20, 2026 17:34
@bdehamer
bdehamer requested a review from a team as a code owner August 20, 2026 17:34
Soften the singleflight 'no stale-serve window' wording (a mid-flight tag move can hand a joiner the leader's resolution; it is concurrent-only coalescing, not a persisted cache), and note that an errored upstream fetch counts as a cache miss — matching the implementation and the PR description.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 815c91eb-313b-4d6c-bbe3-904ed8ad1e35

@piceri piceri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

👍

bdehamer added a commit that referenced this pull request Aug 22, 2026
The provider fetches attestation bundles through go-containerregistry's
DefaultTransport, whose 30s dial and 10s TLS-handshake timeouts are far
longer than a single fetch attempt (-bundle-timeout) and the fail-closed
admission-webhook deadline. When a request is routed (via a geo-replicated
registry's global endpoint) to a degraded replica, it stalls in connection
setup and is cancelled at the deadline without ever retrying against a
healthy replica — so the retry loop effectively collapses to one attempt.

Install a shared http.Transport (cloned from remote.DefaultTransport, so the
idle-pool sizes and HTTP/2 tuning are preserved) whose connection-phase
timeouts are bounded below the per-attempt budget, and wire it in via
remote.WithTransport. A stalled phase is then abandoned early, leaving parent
budget for retryBundle to open a fresh connection (which the global endpoint
may route to a healthy replica).

Rather than bake in constants tuned for one deployment, the phase timeouts
are a decomposition of the per-attempt budget and are derived from it:

  - dial            = 0.6 * bundle-timeout
  - TLS handshake   = 0.6 * bundle-timeout
  - response header = 0.8 * bundle-timeout   (floored at 250ms)

This keeps the provider correct by default at any -bundle-timeout, which
matters for a general-purpose OSS provider run against registries with very
different latency profiles. Operators can still pin an individual phase via
-registry-dial-timeout / -registry-tls-handshake-timeout /
-registry-response-header-timeout (0 = derive); a positive override must be
less than bundle-timeout, validated at startup, so a phase timeout can never
be silently swallowed by the attempt's context deadline.

The transport is a singleton (shared connection pool) rebuilt once at startup
by ConfigureTransport after flags are parsed. The provider can only couple
transport timeouts to bundle-timeout; the outer invariant
(attempts*bundle-timeout + delays < Provider.timeout < webhook timeout) spans
gatekeeper/k8s config and remains a deployment-time concern.

At the deployed bundle-timeout of 2.5s the derived values are dial 1.5s /
TLS 1.5s / response-header 2s. Defense-in-depth alongside the retry budget
and the cache + singleflight de-duplication shipped in v0.2.x (#192).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: adc26119-a4fc-4118-82b4-0601ae564252
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants