Repository navigation
Hold requests to an upstream that rate limits the proxy - #483
Conversation
A 429, or a 503 with Retry-After, from an upstream host now stops the proxy sending that host anything for as long as it asked (capped at an hour), or for a minute doubling up to fifteen when no Retry-After came. Held requests fail locally: cached metadata is served stale as before, and anything uncached answers 503 with the remaining Retry-After so the client paces itself too. The gate is a RoundTripper under the auth transport, so metadata, artifact and passthrough fetches all obey it. Metadata fetches had no retry or circuit breaker, so a rate-limited upstream kept receiving one request per cache miss for the whole block. /health lists held hosts under upstream_holds and proxy_upstream_rate_limit_holds_total counts holds.
andrew
left a comment
There was a problem hiding this comment.
Holding the host at the transport is the right place for this. Three things need changing before it can go in, plus one small cleanup.
A late success clears an active hold. RoundTrip calls g.release(host) on any response that isn't limited, so a request that was already in flight when the hold started and comes back 200 afterwards wipes the hold and resets strikes. Rate limiters often let some requests through, so on a busy proxy this is the common case. Since the gate sends nothing while a hold is active, any response arriving during one was sent before it began: have release only delete a hold whose until has passed, and add a test where a 429 is followed by a late 200 and the hold survives.
Held artifact fetches trip the circuit breaker. CircuitBreakerFetcher in git-pkgs/registries counts every error from breaker.Call as a failure, including HeldError, so after five held cache misses the host's breaker opens. From then on artifact misses get the breaker's 502 without Retry-After, proxy_circuit_breaker_trips_total and /health report an outage, and probes during the hold fail and push the breaker's backoff towards five minutes, which can keep the host blocked after the hold ends. Add a Held(url) to RateLimitGate and check it in getOrFetchArtifactFromURL before calling the fetcher, returning the HeldError directly so it never reaches the breaker, with a test that makes more than five held misses and checks the breaker stays closed.
Most handlers still answer a hold with 502. writeUpstreamHeld is wired into the shared Proxy helpers and Maven metadata, but the handlers that fetch metadata themselves (npm, PyPI, Composer, gem, hex, julia, swift, conan, conda, nuget and the container routes, about 20 HTTPClient.Do / fetchOrCacheMetadata call sites) turn the HeldError into a 502. Add the writeUpstreamHeld check to each of those error branches so every uncached request during a hold gets the 503 and Retry-After the README describes.
RecordUpstreamRateLimitHold(host string, _ time.Duration) only takes the duration to match OnHold and never uses it; drop it from OnHold and the function.
A response arriving during a hold was sent before it began, so a late success no longer releases the host; only one after the hold has ended does. Artifact fetches check RateLimitGate.Held before calling the fetcher, so held cache misses fail with the HeldError without counting as failures in the host's circuit breaker. Every handler that answers an upstream fetch error with 502 now answers a held request with 503 and Retry-After instead. OnHold and RecordUpstreamRateLimitHold drop the unused duration.
|
Thanks. All four are addressed in b2955ef. Late success clears a hold. Held artifact fetches trip the breaker. Added Handlers still answering 502. Added the
|
When an upstream host answers 429, or 503 with
Retry-After, the proxy keeps sending it requests. Metadata fetches have no retry or circuit breaker, so a rate-limited upstream keeps getting one request per cache miss for as long as the block lasts. That can extend the block.This adds a RoundTripper under the auth transport that holds a host after a 429, or a 503 with
Retry-After. While the hold lasts, the proxy sends that host nothing. The hold lasts as long asRetry-Afterasks, capped at an hour. WithoutRetry-Afterit starts at a minute and doubles up to fifteen minutes. Because the gate sits in the shared transport, metadata, artifact and passthrough fetches all obey it.Held requests fail locally:
Retry-After, so the client also slows down.Observability:
/healthlists held hosts underupstream_holds.proxy_upstream_rate_limit_holds_totalcounts holds.Tested with
go build ./...,go vet ./...andgo test ./..., and by running the proxy against a live server.