Deciding who gets told no  / field guide
Practitioner field guide · 2026-09-25

Deciding who gets told no

How production systems refuse requests: the classifier, the counter, the exception path and the failure posture behind every 429, reconstructed from GitLab's public incident tracker and six years of its rate-limiting record, the Envoy and Kubernetes admission machinery, Google's abandoned Doorman, and the self-throttling clients AWS and gRPC ship. After reading it you can design the refusal path of an API, and you will know that the counting algorithm is the part least likely to hurt you.

34 ledger rows, 10 organisations 4 postmortems 5 design records Evidence through September 2026 Read: 25 min
01

The territory

A shared service with finite capacity, many independent clients, and a verdict that must be produced per request, in microseconds, on servers that share no memory.

Strip the technology names away and the problem is this: some of your callers can each, alone, generate more work than the whole system can absorb. You must decide, for every request, whether to do the work or refuse it; the decision has to be roughly consistent across hundreds of processes; and it has to cost almost nothing, because it runs before every piece of work you actually get paid for. Every system in this guide answers with the same five parts: a classifier that decides who a request belongs to, an exception plane that decides who is exempt, a counter against a threshold, a verdict contract that tells the client what to do about the refusal, and a rollout mode that lets you watch a limit before you enforce it.

The surprise in this corpus, and the reason to read on: in four production incidents at GitLab between 2022 and 2026, spanning a Severity 1 outage with 9.49 million failed requests, the counting was never the thing that failed. The classifier lied, the exception plane broke, a limit sat disabled, or the limiter faithfully amplified a failure that started somewhere else. The algorithm section of every rate-limiting tutorial, the token buckets and sliding windows, is the one part of this machinery with a clean production record. Design reviews spend their time on the wrong box.

1.99M
Requests per 5 minutes that normally bypass GitLab.com's limiter, wrongly refused when the bypass path broke, 2024
20ms
Envoy's default budget for asking a remote service whether to admit a request
18
Nodes in the dedicated Redis Cluster that does nothing but count requests for GitLab.com, 2026
39h
Time GitLab's own CI runners were throttled as anonymous traffic before anyone found the misclassification, 2025

Figure 1 · The refusal machinery, and where the incidents actually happened

bypass header, allowlist,
dry run

no

no

yes

client backs off
(gRPC A6, AWS SDK)

Request

Classifier
whose request is this?

Exempt?

Admit, uncounted

Counter
bucket / window / seats

Over threshold?

Admit

Refuse: 429 + Retry-After

bypass header, allowlist,
dry run

no

no

yes

client backs off
(gRPC A6, AWS SDK)

Request

Classifier
whose request is this?

Exempt?

Admit, uncounted

Counter
bucket / window / seats

Over threshold?

Admit

Refuse: 429 + Retry-After

Every request passes the classifier and the exception plane before any counting happens; all four GitLab incidents in this guide sit on those first two boxes or on the threshold configuration, none on the counter. Reconstructed from GitLab #19447, #18174 and the Envoy ratelimit service.
Diagram source

Who the record comes from. GitLab.com is the spine of this guide because it is the only large SaaS operator whose incident reviews, threshold-tuning worksheets, architecture blueprints and merge request arguments about rate limiting are all public and cross-linked. The supporting cast: the Envoy ratelimit service (Lyft's design, now the de facto open-source global limiter), Kubernetes' API Priority and Fairness (KEP-1040), YouTube's Doorman, Netflix's concurrency-limits, the gRPC retry throttle, the AWS SDK's adaptive client limiter, and nginx's leaky bucket, each read from its own repository.

Scope, and an evidence limit

This guide covers request-level admission control: deciding per request, per client, whether to serve. It does not cover volumetric DDoS defence, WAF rules, billing quotas, or utilisation-triggered load shedding (for the overload feedback loops that shedding breaks, see this series' retry-storms guide). One honesty note: this session's research environment could reach repository hosts and package registries but not engineering blogs, paper archives or video hosts. The well-known Stripe, Cloudflare, Figma and GitHub blog accounts of their limiters, and the SIGCOMM 2007 distributed rate limiting paper, are named where relevant but were not fetched, are not linked, and carry no claims here. The blog and paper layers of this topic exist; this page is built from the layer below them, and says so rather than citing from memory.

02

How it is actually built

One shape recurs across every system in the corpus; the divergences are where the counter lives and what it counts.

The reference shape has admission control at three altitudes, and mature operators run all three at once. At the edge, a proxy holds coarse per-IP rules and the emergency knobs: when GitLab took a Severity 1 outage from an unthrottled package endpoint in July 2026, the mitigation deployed mid-incident was a Cloudflare rule, "then codified into Terraform" per the incident review. In the application, middleware sees authenticated identity and applies named throttles per user, per IP, per endpoint class; GitLab runs 21 named throttles in its Rack middleware (MR 233066) plus a second per-action limiter, ApplicationRateLimiter, inside the same codebase. Behind everything sits the counter store: GitLab gives counting its own failure domain, a Redis Cluster named redis-cluster-ratelimiting that grew to six shards and 18 nodes in July 2026 (change #22496).

Figure 2 · Reference architecture: three altitudes, one exception plane

Counter store

Application middleware

Edge proxy (CDN, HAProxy)

governs

governs

Coarse IP rules,
emergency limits

Bypass-header hygiene:
erase on untrusted traffic

Classifier: auth, token type,
endpoint class

Named throttles
(21 at GitLab)

Dedicated Redis cluster
6 shards / 18 nodes

Local negative cache of
over-limit keys

Exception plane: allowlists, bypass header,
per-throttle dry run, disabled flags

Counter store

Application middleware

Edge proxy (CDN, HAProxy)

governs

governs

Coarse IP rules,
emergency limits

Bypass-header hygiene:
erase on untrusted traffic

Classifier: auth, token type,
endpoint class

Named throttles
(21 at GitLab)

Dedicated Redis cluster
6 shards / 18 nodes

Local negative cache of
over-limit keys

Exception plane: allowlists, bypass header,
per-throttle dry run, disabled flags

The exception plane cuts across all three altitudes, which is why a load-balancer change could break application-level bypass in GitLab #18174. Envoy's variant replaces in-process counting with a 20 ms gRPC call to a dedicated decision service.
Diagram source

The classifier

Turns a request into a limit key: which throttle applies, and what identity to count it under. It re-implements authentication, which is exactly where it goes wrong: GitLab's middleware "attempted to look up the user associated with the PAT, and finding none, assumed this was an unauthenticated request" while the API layer authenticated the same request fine, for months.

Record: GitLab #19447, rack-attack discriminators, Envoy descriptors

The counter

Fixed windows in Redis (GitLab), leaky bucket in shared memory (nginx's per-key excess), token bucket in-process (Go's x/time/rate), GCRA when you want rolling windows and a Retry-After from one atomic call (redis-cell). Every over-limit key is also cached locally in Envoy's service so the store isn't re-asked about a client it already refused.

Record: nginx, x/time/rate, redis-cell

The exception plane

Allowlists, bypass headers, dry-run flags, per-customer disables. At GitLab this plane carried roughly two million requests per five minutes, two orders of magnitude more traffic than enforcement ever touched. It is configured in load balancers, environment variables and database columns at once, which is why it breaks.

Record: GitLab admin docs, #18174

The verdict contract

What the refused client is told: HTTP 429 with Retry-After, rate-limit headers, or gRPC's explicit server pushback ("retry after a given delay or ... not retry at all"). The Go limiter API is a compact statement of the three possible semantics: Allow (refuse now), Reserve (admit later), Wait (block the caller).

Record: gRFC A6, x/time/rate

The cooperating client

Serious platforms do not leave backoff to the caller's goodwill; they ship it. The AWS SDK runs a CUBIC congestion controller against throttling responses inside botocore. gRPC stops its own retries when a per-server token count falls below half its maximum. Doorman's 2016 argument for this still stands: a refused request has already "burnt the network capacity and done some processing".

Record: botocore adaptive.py, gRFC A6, Doorman design

The rollout mode

Dry run and shadow are not conveniences, they are part of the architecture. GitLab ran its first throttles in dry-run mode for two months before enforcing; Envoy's ratelimit service has a global shadow mode "to introduce rate limiting into an existing service landscape"; and GitLab's 2026 limiter replacement runs old and new limiters in parallel, recording decision divergence.

Record: change #3034, ratelimit README, #22496

The deepest divergence across the corpus is not where the counter lives but what it counts. The Rack, nginx and Envoy families count requests per unit time. Kubernetes and Netflix both concluded that requests are the wrong unit. KEP-1040 replaced the API server's flat max-in-flight with "execution seats" allocated by shares and lendable between priority levels, because a LIST costs a different amount than a GET and "one subset of the request load crowds out other parts". Netflix's concurrency-limits library makes the same move from the other end: an RPS limit derived from a stress test "quickly goes out of date" under autoscaling, so measure concurrency (Little's law: limit equals average RPS times average latency) and adapt it with TCP congestion algorithms. Two teams, two directions, one conclusion: when request cost varies or capacity moves, count occupancy, not arrivals.

03

The decisions that matter

Five forks, each with the condition that flips it. The first two get all the interview attention; the last three cause the incidents.

Decision 1: What is the unit, requests per second or occupied capacity?

Chosen
  • Requests per window: GitLab (500/min unauthenticated, 2,000/min authenticated API as enforced in 2021), nginx, Envoy, every ecosystem middleware
  • It is explainable to customers, cheap to count, and maps to a published API contract
Rejected (by two teams)
  • Kubernetes replaced flat request counts with seats and shares (KEP-1040); Netflix rejects stress-tested RPS limits outright because autoscaling invalidates them
Flips when
  • Request cost variance is high (LIST vs GET) or capacity changes under you; then count concurrency. For a public paid API, RPS stays, because the limit is a product surface, not only a protection

Decision 2: Where does the counter live?

Chosen
  • In-process, no coordination: nginx, x/time/rate. Zero added latency, each node enforces limit divided by node count
  • Shared store: GitLab's dedicated 18-node Redis cluster
  • Dedicated service: Envoy's gRPC ratelimit, 20 ms default budget
Rejected
  • Exact global coordination. Envoy's own docs stage local buckets in front so bursts don't overwhelm the global service; the quota-based fair-sharing mode's "open source reference implementation ... is currently unavailable"
Flips when
  • Per-client fairness across a fleet is a product promise: then you pay for the shared store and give it its own failure domain and capacity plan, like any other stateful dependency

Decision 3: What happens when the limiter itself fails?

Chosen
  • Fail open. Envoy's failure_mode_deny defaults to false: on limiter failure, traffic flows
  • Doorman named three postures in 2016: pessimistic (zero), optimistic (everything), and "safe mode", a configured degraded capacity
Rejected
  • Fail closed by default, because it turns a limiter blip into a full outage of everything behind it
Flips when
  • The limit is a security control (login brute force) or a billing control; then over-admission is the outage. Envoy now ships the middle posture as failure_mode_deny_percent: deny a configured fraction while the service is down

Decision 4: Does the server refuse, or does the client cooperate?

Chosen
  • Server-side refusal plus shipped client discipline: gRPC's retry throttle (stop retrying below maxTokens/2), botocore's CUBIC limiter reacting to throttling errors
Rejected (by abandonment)
  • Doorman's full cooperative model, where clients lease capacity ahead of time. Technically coherent; the open-source project stopped ten weeks after its first commit, and its planned C++ and Python clients never shipped
Flips when
  • You own every client (internal batch jobs against a shared database): leases recover the capacity that refusal burns. You do not own the clients of a public API, so the SDK is the only client you get to program

Decision 5: Are exceptions a bypass or a bigger limit?

Chosen (and regretted)
  • Binary bypass: GitLab's edge header, user-ID allowlists, and full disables for large customers
The operator's own verdict
  • GitLab's 2022 blueprint: disabling rate limiting for marquee customers "increases a risk for those same customers. We should instead be able to set higher limits"
Flips when
  • Never observed to flip back. Every incident in section 04 that touches the exception plane argues for overrides that keep counting (higher thresholds, still measured) over bypasses that stop counting

Figure 3 · Choosing the shape of your limiter

yes

no

no

yes

yes, with its own
failure domain

no

Does request cost vary a lot,
or does capacity autoscale?

Count occupancy:
seats / adaptive concurrency
(Kubernetes APF, Netflix)

Is per-client fairness
a cross-fleet promise?

In-process bucket per node
(nginx, x/time/rate)

Can you afford a
counting dependency?

Shared store or decision service
(GitLab Redis cluster, Envoy RLS)

Two stages: local bucket
absorbs bursts, global refines
(Envoy documented pattern)

yes

no

no

yes

yes, with its own
failure domain

no

Does request cost vary a lot,
or does capacity autoscale?

Count occupancy:
seats / adaptive concurrency
(Kubernetes APF, Netflix)

Is per-client fairness
a cross-fleet promise?

In-process bucket per node
(nginx, x/time/rate)

Can you afford a
counting dependency?

Shared store or decision service
(GitLab Redis cluster, Envoy RLS)

Two stages: local bucket
absorbs bursts, global refines
(Envoy documented pattern)

The decision order that the record supports: unit first, coordination second, posture third. Terminal nodes name systems that run that answer in production, per the KEP, Netflix and Envoy records.
Diagram source
DecisionChosen in the recordRejectedBecauseEvidence
UnitRequests per window at the API edge; seats or concurrency inside the platformOne unit everywhereRPS is a sellable contract; occupancy tracks true costKEP-1040, Netflix
Counter placementLocal, or dedicated shared store, or 20 ms decision serviceExact global countingBursts overwhelm a global service; stage local buckets in frontEnvoy arch docs
Failure postureFail open by default, fractional deny availableFail closed by defaultA limiter blip must not become a platform outagerate_limit.proto, Doorman
Client roleShip backoff in the SDKCooperative capacity leasesLeases need owned clients; Doorman stalled in 2016gRFC A6, doorman repo
ExceptionsBypass and allowlists (widespread)Higher limits that keep countingOperator's own blueprint calls bypass a risk to the bypassedGitLab blueprint
RolloutDry run, then enforce; shadow for replacementsEnforce on deployDry run caught transposed thresholds before users didissue 656
04

What broke in production

Four incidents, four failure classes, one pattern: the counter is innocent. Every failure lives in identity, exceptions, configuration, or amplification.

These four are all from one operator, which is a limit of the public record, not of the lesson: GitLab is the only organisation publishing rate-limiter incidents at this depth. Treat the classes, not the company, as the finding. Each class is a different answer to the question "what did the limiter believe that was false?"

Postmortem

Class 1 · The classifier lies: CI runners throttled as anonymous strangers

AssumptionThe limiter and the API agree on who a request belongs to.
What happenedRunner v16.10 started sending a PRIVATE-TOKEN header alongside the job token. The API authenticated via the body token; Rack::Attack tried the header, found no user, and filed the request as unauthenticated, under a far lower per-IP limit. The defect was latent "for a long time" because a wrong verdict only changes a threshold; nothing failed until pipeline growth crossed it.
Blast radius39.15 hours, March 2025. Succeeded CI jobs stuck "running"; a broken runner retry (empty body on retry) compounded it.
FixMake the limiter's token lookup fall through instead of raising (MR 183764); stated direction: "a single source of truth for the job token auth code in both RackAttack and in the api", and alerting when one layer authenticates what another rejects.
Design ruleThe limiter's identity decision must be the same code path as the API's, or continuously reconciled against it. A classifier defect is invisible until load arrives, so test it with traffic replay, not unit tests alone.
Postmortem

Class 2 · The exception plane breaks: the allowlist gets rate limited

AssumptionBypass is a property of the application config; the edge merely forwards.
What happenedAn HAProxy configuration change altered how application-level rate limiting saw users. Traffic that normally bypassed the limiter, roughly 2,000,000 requests per 5-minute window, dropped to 15,000 bypassed; the difference got counted and refused. The customers hit were precisely the allowlisted ones.
Blast radiusJune 2024; ~50 root namespaces, Git-over-HTTPS worst affected. Detected by a customer support ticket, not a monitor.
FixRevert; corrective actions to log config changes centrally and alert on 429 rate.
Design ruleThe bypass path is production infrastructure with more traffic than the enforcement path. Give it what enforcement has: tests, a synthetic probe, and an alert on bypass volume shifting.
Postmortem

Class 3 · The limit that was off: one user, one endpoint, Severity 1

AssumptionThe package API was covered, because a throttle for it exists.
What happenedA single user's burst of Nix package downloads updated a last-downloaded-at column on every GET, "creating a PostgreSQL lock convoy" that saturated PgBouncer, Puma and Workhorse. The relevant RackAttack throttle "was disabled in production", so an existing protection never engaged.
Blast radiusJuly 2026, Severity 1, 77 minutes; ~9.49M HTTP 503s in the 15-minute peak; PgBouncer waiters peaked at 3,680; web, API, Git, CI and registry all degraded.
FixEmergency Cloudflare rule (then codified in Terraform), plus a code fix throttling the write-on-read.
Design ruleA defined-but-disabled limit is worse than no limit: it shows up in coverage reviews. Inventory limits by enforcement state, not existence, and treat any GET that writes as needing its own limit.
Postmortem

Class 4 · The limiter amplifies someone else's failure: 429 on the login page

AssumptionA 429 means the client asked for too much.
What happenedAn OAuth secrets migration broke sign-in via GitHub, Salesforce and Bitbucket. The resulting redirect loop generated repeated requests per user, which the login throttle then refused. Users experienced "too many requests" as the face of a credentials outage, obscuring the root cause.
Blast radiusNovember 2022; 390 minutes of failed OAuth logins.
FixRestore the migrated secrets; public incident review of the process.
Design ruleA spike in refusals is a symptom detector for failures upstream of the limiter. Alert on 429 rate as an incident signal, and make the refusal page distinguish "you are over your limit" from "we are refusing everyone who does X" in your own diagnostics.

Figure 4 · INC-12449: the path the disabled throttle would have cut

Everyone elsePostgreSQLPackage APIRackAttack throttleOne Nix clientEveryone elsePostgreSQLPackage APIRackAttack throttleOne Nix clientlock convoy on a fewrowsburst of package GETs429 (throttle disabled: never sent)all admittedUPDATE last_downloaded_atper GETwaits pile up (3,680 PgBouncerwaiters)normal traffic~9.49M HTTP 503s in 15 min
Everyone elsePostgreSQLPackage APIRackAttack throttleOne Nix clientEveryone elsePostgreSQLPackage APIRackAttack throttleOne Nix clientlock convoy on a fewrowsburst of package GETs429 (throttle disabled: never sent)all admittedUPDATE last_downloaded_atper GETwaits pile up (3,680 PgBouncerwaiters)normal traffic~9.49M HTTP 503s in 15 min
The refusal that would have cost one user a slower download instead cost every user 77 minutes; the dashed message is the control that existed but was off. Reconstructed from the INC-12449 review.
Diagram source

What is missing from the public record matters as much: no postmortem in this corpus attributes an outage to the counting algorithm being wrong, and none attributes one to a counter store failing while configured fail-open. Either that failure mode is rare, or, like fail-open failures generally, it is invisible: when the limiter silently admits everything and the backend survives, nobody writes it up. Envoy's July 2026 commit adding READONLY handling after Redis failovers shows the store-failure path is being hit; the absence of incident reports about it is a detection gap, not evidence of safety.

05

Numbers you can plan against

Everything here is measured or read from a primary artifact; nothing is a vendor benchmark. Dates matter: thresholds move.

MetricValueAtContextAs ofSource
First enforced thresholds500/min unauth IP; 2,000/min auth API; 1,000/min auth webGitLab.comAfter two months of dry-run tuning2021-01issue 656
Dry-run period before enforcement~2 monthsGitLab.com2020-11-20 dry run to 2021-01-18 enforcement; caught transposed limits2021issue 656
Named throttles in middleware21GitLab.comRack::Attack alone, excluding ApplicationRateLimiter and edge rules2026-04MR 233066
Dedicated counting cluster6 shards, 18 nodes (c2-standard-4)GitLab.comRedis Cluster for rate limiting only; grown for shadow rollout doubling counter ops2026-07change #22496
Traffic on the bypass path~2.0M requests / 5 minGitLab.comAllowlisted traffic normally skipping the limiter; measured when it broke2024-06incident #18174
Decision budget for a remote verdict20 ms defaultEnvoyTimeout on the gRPC rate limit call; expired timeout admits unless deny is configured2026-09 (checked)rate_limit.proto
Client retry throttlemaxTokens 10, tokenRatio 0.1, stop below maxTokens/2gRPCExample config; failures cost 1 token, successes restore 0.12026-09 (checked)gRFC A6
Seat allocation formulaNominalCL(i) = ceil(ServerCL × ACS(i) / sum_acs)KubernetesConcurrency shares per priority level, with lendable and borrowing percentages2026-09 (checked)KEP-1040
Cost of the missing limit9.49M 503s / 15 min; 77 min S1GitLab.comOne user, one unthrottled endpoint, lock convoy2026-07INC-12449
Latency of a classifier defectmonths latent, 39.15 h to resolveGitLab.comMisclassification changed a threshold, not an outcome, until load crossed it2025-03incident #19447
Life of the cooperative limiter70 commits, last 2016-05-02YouTube / DoormanOpen-sourced global client-side limiter; planned clients never shipped2026-09 (checked)doorman repo
Age of commodity middlewarefirst publish 2014-12-11; v8.7.0 2026-08-29npm / express-rate-limitPer-endpoint refusal has been a package install for over a decade2026-09npm registry
Read these carefully

The GitLab thresholds are that operator's numbers for that traffic in 2021; they are a worked example of how to derive limits (from observed percentiles, in dry run), not values to copy. The 20 ms Envoy budget and the gRPC token numbers are shipped defaults, which is a statement of what their authors consider sane, not a measurement. Nobody in this corpus publishes the steady-state cost of counting; the closest artifact is GitLab's 18 reserved c2-standard-4 nodes, which is a lower bound on what fleet-wide fairness costs at their scale as of mid-2026.

Figure 5 · Six years to learn to say no: GitLab.com's rate-limiting record

2020-21dry run, thresholdstuned liveenforced at 500/minunauth2022blueprint calls bypassa riskOAuth loop, 429 onlogin2024HAProxy changebreaks bypass2025classifier throttles ownrunners2026disabled throttle in anS1shadow rollout,18-node cluster
2020-21dry run, thresholdstuned liveenforced at 500/minunauth2022blueprint calls bypassa riskOAuth loop, 429 onlogin2024HAProxy changebreaks bypass2025classifier throttles ownrunners2026disabled throttle in anS1shadow rollout,18-node cluster
The arc from first dry run to a unified limiter is six years and counting, with the architecture documents arriving after the incidents they explain. Dates from the change trail, blueprint and epic 2021.
Diagram source
06

The evidence wall

Every source behind this page, graded. The full ledger, with one quoted claim per row, ships beside this file as sources.md. Blog, paper and talk tiers are absent because their hosts were unreachable in this research session; that absence is stated, not papered over.

Postmortem GitLab2024-06

Incident review: uptick in 429 errors

An HAProxy change broke the rate-limit bypass; ~1.985M requests per 5 minutes that normally skip the limiter were refused. Impact concentrated on allowlisted customers. Detected by a support ticket.

Carry forwardThe exemption path carries more traffic than the enforcement path; monitor its volume like a product feature.
gitlab.com/gitlab-com/gl-infra/production/-/issues/18174
Postmortem GitLab2025-03

Incident review: succeeded jobs stuck in running

Rack::Attack classified runner job-updates as unauthenticated because it tried the wrong token first, while the API authenticated them fine. Latent for months; 39 hours to resolve once load crossed the lower threshold.

Carry forwardOne identity code path for limiter and API, and an alert when their verdicts diverge.
gitlab.com/gitlab-com/gl-infra/production/-/work_items/19447
Postmortem GitLab2026-07

Incident review INC-12449: HTTP timeouts over 30 s

One user's package-download burst, an UPDATE on every GET, a PostgreSQL lock convoy, and a Severity 1: ~9.49M 503s in the 15-minute peak. The covering throttle existed and was disabled in production.

Carry forwardAudit limits by enforcement state; a disabled throttle passes every design review and stops nothing.
gitlab.com/gitlab-com/gl-infra/production/-/work_items/22584
Postmortem GitLab2022-11

GitLab login fails with 429 error

A secrets migration broke OAuth sign-in; the redirect loop tripped the login throttle, and for 390 minutes users saw "too many requests" instead of the real failure.

Carry forwardA 429 spike is an incident signal for the thing behind the limiter, not only for abusive clients.
gitlab.com/gitlab-com/gl-infra/production/-/issues/8101
Decision record GitLab2026-04

Epic 2021: rate limits, Rails unification

Four years after the blueprint: seven-plus limiter implementations, inconsistent dry run and bypass, new endpoints unlimited by default. The unification design centres on a call-site name plus request identifier, i.e. on identity, not on counting.

Carry forwardBudget for consolidation: limits accrete implementations faster than they get unified.
gitlab.com/groups/gitlab-com/gl-infra/-/epics/2021
Source GitLab2020-2021

Issue 656: the threshold-tuning worksheet

The dated table of every threshold change from first dry run (2020-11-20) to enforcement (2021-01-18), including the week the two authenticated limits were accidentally transposed, caught by dry-run telemetry.

Carry forwardDerive limits from observed traffic in dry run; keep the change log in one table.
gitlab.com/gitlab-com/gl-infra/observability/team/-/work_items/656
Source GitLab2026-07

Change #22496: a sixth shard for the counting cluster

The dedicated redis-cluster-ratelimiting grows to 18 reserved nodes, sized ahead of a shadow rollout that runs old and new limiters in parallel, roughly doubling counter operations.

Carry forwardCounting is a stateful workload with its own capacity plan; replacing a limiter costs double while you compare.
gitlab.com/gitlab-com/gl-infra/production/-/issues/22496
Source GitLab2026-04

MR 233066, closed unmerged: per-throttle dry run and allowlists

A change giving each of the 21 throttles its own dry-run flag and allowlist columns, closed without merging as the plan was re-cut around the labkit unification. The exception plane's own configuration is still in motion in 2026.

Carry forwardPer-throttle rollout controls are wanted enough to be built twice; design them in from the start.
gitlab.com/gitlab-org/gitlab/-/merge_requests/233066
Source GitLab2025-03

MR 183764: the classifier fix

The merged repair for incident #19447: the limiter's token lookup returns blank for CI build tokens instead of raising, so authentication falls through to the job token.

Carry forwardThe fix is one conditional; the cost was 39 hours. Cheap code, expensive blindspot.
gitlab.com/gitlab-org/gitlab/-/merge_requests/183764
Operator docs GitLabchecked 2026-09

User and IP rate limits (admin documentation)

The operational contract of the exception plane: a bypass header the edge must erase on untrusted traffic, user-ID allowlists, per-throttle dry run via environment variable, and a documented recovery for locking your own administrators out.

Carry forwardIf the edge fails to strip your bypass header, clients can grant themselves exemption; hygiene there is a security control.
gitlab.com/gitlab-org/gitlab/-/blob/master/doc/administration/settings/user_and_ip_rate_limits.md
Source Envoy (Lyft origin)checked 2026-09

envoyproxy/ratelimit: the reference decision service

A gRPC service over Redis keyed by domain and descriptors, with a local cache of over-limit keys so refused clients stop costing Redis reads, and a global shadow mode for introducing limits into an existing landscape.

Carry forwardCache the "no"s locally: the clients you refuse are the ones hammering you.
github.com/envoyproxy/ratelimit
Source Envoychecked 2026-09

rate_limit.proto: the failure posture as a config field

failure_mode_deny defaults to false (fail open), the remote verdict gets 20 ms, and failure_mode_deny_percent lets an operator deny a fraction while the limiter is down: a shipped dial between open and closed.

Carry forwardDecide your posture explicitly and write it down; the default is open and most teams never learn they chose it.
github.com/envoyproxy/envoy/.../ratelimit/v3/rate_limit.proto
Source Envoy2026-07

Commit cfdbd87: READONLY replies after Redis failover

The limiter's own store failover is live operational work in 2026: pooled connections can stick to a demoted primary and answer READONLY until explicitly closed.

Carry forwardGame-day the counter store's failover; the limiter is a stateful service with all the usual failure modes.
github.com/envoyproxy/ratelimit/commit/cfdbd87
Operator docs Envoychecked 2026-09

Global rate limiting (architecture overview)

When global counting earns its keep: many hosts converging on few, latency too low for circuit breaking to bite. And the two-stage pattern: local token buckets absorb bursts so the global service survives them.

Carry forwardLocal first, global second; the global limiter needs protecting from exactly the bursts it exists to stop.
github.com/envoyproxy/envoy/.../global_rate_limiting.rst
Decision record YouTube / Google2016

Doorman design doc

The cooperative alternative: clients lease capacity from a server hierarchy ahead of use, because refusal burns the network and compute you already spent. Names three degraded postures: pessimistic, optimistic, and a configured safe capacity.

Carry forward"Safe capacity" is the posture the industry keeps rediscovering; Envoy's deny-percent is the same idea eight years later.
github.com/youtube/doorman/blob/master/doc/design.md
Source YouTube / Google2016-05

The Doorman repository as an artifact

70 commits, the last on 2016-05-02; README status "Alpha quality"; the planned C++ and Python clients never appeared. The most architecturally ambitious limiter in the corpus is also the shortest-lived.

Carry forwardCooperative limiting needs every client ported; that adoption cost, not the algorithm, is what killed it.
github.com/youtube/doorman
Decision record Kubernetes2019, implemented

KEP-1040: Priority and Fairness for API server requests

The motivation section is a failure catalogue: Deployment of Doom, Kubelet Amuck, self-maintenance crowded out. The design replaces undifferentiated max-inflight with seats, shares, and borrowing between priority levels.

Carry forwardA flat limit protects the server but not the workload mix; fairness requires classifying before counting.
github.com/kubernetes/enhancements/.../1040-priority-and-fairness
Source Netflixchecked 2026-09

concurrency-limits: the case against RPS

Stress-tested RPS limits go stale as systems autoscale; the library measures the concurrency limit instead, adapting it with TCP congestion algorithms (Vegas, Gradient2) and partitioning it between traffic classes, e.g. live 90% / batch 10%.

Carry forwardLittle's law converts your latency SLO into a concurrency limit; that number self-tunes where an RPS number rots.
github.com/Netflix/concurrency-limits
Decision record gRPCchecked 2026-09

gRFC A6: retry throttling

The client refuses on the server's behalf: a per-server token count, minus one per failure, plus 0.1 per success, and no retries below half the maximum. Servers can push back with an explicit do-not-retry.

Carry forwardRetry storms are a client-side admission problem; throttle retries where they are born.
github.com/grpc/proposal/blob/master/A6-client-retries.md
Source AWSchecked 2026-09

botocore adaptive retry mode

The SDK carries a client-side rate limiter: a token bucket whose fill rate is set by a CUBIC congestion calculator reacting to throttling errors, hooked into every request send and retry decision.

Carry forwardIf you publish an SDK, you own half the admission control; ship the backoff, don't document it.
github.com/boto/botocore/blob/develop/botocore/retries/adaptive.py
Source nginxchecked 2026-09

ngx_http_limit_req_module.c

The most-deployed limiter is a leaky bucket in C: per-key excess tracked in a shared memory zone, per node, no external store, no cross-fleet view. The baseline every fancier design is implicitly compared against.

Carry forwardPer-node counting with limit/N per node is often enough; know what fairness you are actually buying before adding a store.
github.com/nginx/nginx/.../ngx_http_limit_req_module.c
Source Brandur Leachchecked 2026-09

redis-cell: GCRA as a Redis module

The generic cell rate algorithm gives a rolling window with no drip process; CL.THROTTLE returns the verdict, the remaining budget and the retry-after in one atomic call, with limits passed per invocation so they change without redeploys.

Carry forwardReturn Retry-After from the same atomic operation that refuses; a separate computation will drift.
github.com/brandur/redis-cell
Operator docs Go teamchecked 2026-09

golang.org/x/time/rate

The standard Go limiter's API is a taxonomy of refusal semantics: Allow (drop it now), Reserve (admit it later), Wait (make the caller absorb the delay). Most designs pick one without noticing the other two exist.

Carry forwardChoose drop, delay or block per call site; batch work wants Wait, user-facing paths want Allow plus Retry-After.
pkg.go.dev/golang.org/x/time/rate
Source Ecosystems2014-2026

The commodity layer: express-rate-limit, limits, governor

Every packaging ecosystem carries a standard limiter: npm's express-rate-limit (first published 2014-12-11, v8.7.0 in August 2026), Python's limits (5.8.0), Rust's governor (0.10.4). The algorithms are solved; everything above them in this guide is not.

Carry forwardNever hand-roll the bucket; spend the design time on identity, exceptions and rollout, where the incidents are.
registry.npmjs.org/express-rate-limit
Source Rack::Attack / GitLabchecked 2026-09

rack-attack and ApplicationRateLimiter

The two limiter families inside one Rails codebase: middleware throttles keyed by a caller-supplied discriminator with state in Rails.cache, and a per-action limiter with its own thresholds and allowlist arguments. GitLab's epic counts "5+ other" beyond these.

Carry forwardLimiters multiply; without a named owner and a registry, you will not be able to answer "what limits apply to this request?"
github.com/rack/rack-attack
07

Build a miniature, then productionise it

Six rungs. The crossing from toy to real is rung four, where you break your own limiter and have to pick a posture.

An in-process bucket under load

Wrap one endpoint of a toy API with a token bucket (use your ecosystem's standard library: x/time/rate, limits, governor). Drive it with a load generator at 2x the limit and plot admitted vs refused per second.

Done when: a burst of exactly burst-size is admitted, steady state holds the configured rate, and refusals carry Retry-After.  Teaches: burst vs rate are independent knobs, and refusal is an API response you design, not an error.

Two processes, one counter

Run two instances behind a load balancer, move the counter to Redis with INCR plus EXPIRE, then make the read-check-write atomic (Lua or GCRA via redis-cell). Compare per-node counting (limit/2 each) against the shared counter under skewed load.

Done when: you can state the over-admission of each design under a 90/10 traffic skew, with numbers.  Teaches: what fleet-wide fairness costs, and what per-node counting silently tolerates.

A classifier and named throttles

Add authentication, then three named throttles: per-IP unauthenticated, per-user authenticated, per-endpoint expensive-path. Log every verdict with throttle name and identity, GitLab-style.

Done when: a request with a bad token and a request with no token hit different throttles, and you can grep the log for who was refused by which rule.  Teaches: the classifier is authentication re-implemented, with all its edge cases.

Kill the counter store

Stop Redis mid-load-test. Implement all three Doorman postures behind a flag: optimistic (admit all), pessimistic (refuse all), safe capacity (per-node local bucket at a fraction of the limit). Add Envoy's refinement: a percentage dial between open and closed.

Done when: you can produce a table of over-admission and false-refusal for each posture during a 60-second store outage.  Teaches: the posture is a business decision you must be able to defend per endpoint; login and checkout want different answers.

Dry run against replayed traffic

Add a dry-run flag per throttle that logs would-refuse without refusing. Replay a day of access logs, and derive thresholds from the observed per-identity percentiles, the way GitLab's issue 656 worksheet does.

Done when: you can name how many real identities your proposed limit would have refused yesterday, before any user feels it.  Teaches: thresholds are empirical; the two-month gap between GitLab's dry run and enforcement was the product.

The exception plane, instrumented

Add a bypass header stripped at your edge, a per-throttle allowlist, and a counter for bypassed traffic. Alert when bypass volume shifts by more than N% hour over hour. Then break the edge stripping deliberately and watch the alert fire.

Done when: a synthetic canary detects both failure directions: exempt traffic getting counted, and untrusted traffic self-exempting.  Teaches: the exception plane is the highest-traffic, least-tested path; GitLab's 2024 incident was exactly this, undetected by monitoring.

08

Keep hunting

The queries that found this material, adapted for a reader with an open network. The GitLab tracker searches work today, verbatim.

Incident record (highest yield)

  • site:gitlab.com gl-infra/production "incident review" "rate limit"
  • RackAttack search in gitlab-com/gl-infra/production issues
  • "429" "incident review" allowlist bypass
  • "rate limiter" postmortem "fail open"

The argument layer

  • repo:envoyproxy/ratelimit is:pr is:closed is:unmerged
  • failure_mode_deny site:github.com
  • "shadow mode" OR "dry run" throttle merge request
  • path:keps "priority and fairness" alternatives

Vocabulary that unlocks the field

  • "safe capacity" rate limiting lease
  • GCRA "generic cell rate" redis
  • "retry throttling" tokenRatio
  • "concurrency limit" "Little's law" adaptive

The layer this session could not reach

  • stripe "scaling your api with rate limiters"
  • cloudflare "counting things" rate limiting
  • github "sharded, replicated rate limiter" redis
  • "distributed rate limiting" SIGCOMM 2007 Raghavan
09

References

  1. GitLab, Incident Review: Uptick in 429 errors, unexpected authentication errors GitLab production tracker, 2024-06. Checked 2026-09-25.
  2. GitLab, 2022-11-30: GitLab login fails with 429 error GitLab production tracker, 2022-11. Checked 2026-09-25.
  3. GitLab, Incident Review INC-12449: gitlab.com HTTP timeout over 30s GitLab production tracker, 2026-07. Checked 2026-09-25.
  4. GitLab, Incident Review: 2025-03-06: Some succeeded gitlab-org jobs are stuck in running GitLab production tracker, 2025-03. Checked 2026-09-25.
  5. GitLab, MR 183764: Fix Rack Attack incorrectly rate limiting runner API gitlab-org/gitlab, merged 2025-03-07. Checked 2026-09-25.
  6. GitLab, Next Rate Limiting Architecture (blueprint) gitlab-org/gitlab at v16.0.0-ee, authored 2022-09-08. Checked 2026-09-25.
  7. GitLab, Epic: Rate Limits, Agentic Implementation Plan, Phase 1: Rails unification gl-infra epics, created 2026-04-17. Checked 2026-09-25.
  8. GitLab, Enable Rack::Attack rate limiting for authenticated and unauthenticated requests Scalability issue 656, 2020-11 to 2021-01. Checked 2026-09-25.
  9. GitLab, Change #3034: Enable RackAttack rate-limiting in dry-run mode GitLab production tracker, 2020-11-16. Checked 2026-09-25.
  10. GitLab, Change #22496: Add new nodes for the 6th shard to redis-cluster-ratelimiting GitLab production tracker, 2026-07-10. Checked 2026-09-25.
  11. GitLab, User and IP rate limits (administration documentation) gitlab-org/gitlab, master. Checked 2026-09-25.
  12. GitLab, MR 233066: Unify RackAttack configuration (closed unmerged) gitlab-org/gitlab, 2026-04-23. Checked 2026-09-25.
  13. GitLab, lib/gitlab/application_rate_limiter.rb gitlab-org/gitlab, master. Checked 2026-09-25.
  14. Envoy, ratelimit: reference global rate limit service github.com, master. Checked 2026-09-25.
  15. Envoy, HTTP rate limit filter configuration (rate_limit.proto) envoyproxy/envoy, main. Checked 2026-09-25.
  16. Envoy, commit cfdbd87: redis READONLY handling (#1191) envoyproxy/ratelimit, 2026-07-27. Checked via git history 2026-09-25.
  17. Envoy, Global rate limiting (architecture overview) envoyproxy/envoy, main. Checked 2026-09-25.
  18. Google / YouTube, Doorman: Global Distributed Client-Side Rate Limiting (design doc) youtube/doorman, 2016. Checked 2026-09-25.
  19. Google / YouTube, Doorman repository Last commit 2016-05-02. Checked 2026-09-25.
  20. Kubernetes, KEP-1040: Priority and Fairness for API Server Requests kubernetes/enhancements, created 2019-02-28, implemented. Checked 2026-09-25.
  21. Netflix, concurrency-limits github.com, master. Checked 2026-09-25.
  22. gRPC, gRFC A6: client retries (retry throttling) grpc/proposal, master. Checked 2026-09-25.
  23. AWS, botocore/retries/adaptive.py (adaptive client rate limiter) boto/botocore, develop. Checked 2026-09-25.
  24. nginx, ngx_http_limit_req_module.c nginx/nginx, master. Checked 2026-09-25.
  25. Brandur Leach, redis-cell: a Redis module implementing GCRA github.com, master. Checked 2026-09-25.
  26. Rack::Attack, middleware for blocking and throttling github.com, master. Checked 2026-09-25.
  27. Go team, golang.org/x/time/rate package documentation pkg.go.dev. Checked 2026-09-25.
  28. npm registry record: express-rate-limit First published 2014-12-11; v8.7.0 2026-08-29. Checked 2026-09-25.
  29. PyPI registry record: limits 5.8.0 Checked 2026-09-25.
  30. crates.io index record: governor 0.10.4 Checked 2026-09-25.

Named but not fetched in this session, and therefore carrying no claims above: Stripe's "Scaling your API with rate limiters" (2017), GitHub's "How we scaled the GitHub API with a sharded, replicated rate limiter in Redis" (2021), Cloudflare's "How we built rate limiting capable of scaling to millions of domains" (2017), Figma's "An alternative approach to rate limiting", and Raghavan et al., "Cloud Control with Distributed Rate Limiting" (SIGCOMM 2007). Their hosts were outside this session's network policy; treat them as the next layer of reading, not as sources of this page.