API Rate Limiting: Protect Work, Not Just Requests
Design API rate limits around identity, operation cost, burst behavior, and failure policy. Compare counters with concurrency limits and test boundary cases.
API rate limiting should protect the work your system can afford to perform. Counting every request equally is convenient, but it can hide a large difference between reading one small record and generating a report across a year of data.
For a hypothetical analytics service, I would separate ordinary reads from report creation. The first policy protects shared request capacity. The second protects a scarce worker pool and database budget. A single “requests per minute” setting cannot express both requirements clearly.
Choose the identity and the resource
Start with the actor you want to hold accountable: an account, API credential, user, or anonymous network source. I would use an authenticated account budget for the analytics API, with a narrower credential budget where integrations need isolation. Shared IP addresses should not be treated as though they always represent one person.
Then decide what the limit measures. My proposed service might use request units for inexpensive reads, separate report-start units for expensive work, and an in-flight report cap. These are illustrative design categories, not measured capacity recommendations.
Write the policy as a sentence before implementing it: “Each workspace may have two reports executing, and may enqueue a bounded number of additional reports.” That statement makes the product behavior easier to review than a Redis key naming scheme.
Understand the boundary behavior of a counter
A fixed-window limiter counts work within a time bucket. Redis provides an official fixed-window rate-limiting tutorial illustrating counter-based enforcement and expiration. That is one implementation approach, not a universal fairness model.
Consider a hypothetical limit of 100 requests per minute. A caller can send 100 at the end of one minute and 100 immediately after the next begins. Both windows satisfy the rule, yet a short interval sees 200 requests. This is arithmetic about the proposed policy, not a measured Redis result.
If that burst is unacceptable, choose a policy that models the permitted burst more directly, or combine the rate budget with concurrency control. I would not hide a fixed-window boundary under marketing language like “smooth traffic.” The implementation should deliver the behavior the policy actually describes.
Make shared enforcement atomic
If multiple application instances check a counter and then increment it independently, a race can admit more work than intended. In a proposed Redis design, the decision and counter update should be one atomic operation, with expiration established consistently.
Do not copy a two-step sketch into production without analyzing the crash between increment and expiry. Likewise, distinguish a rejected attempt from admitted work: counting both may be intentional, but the policy must say so.
A minimal test fixture would start several callers simultaneously when one unit remains. The result should match the documented allowance. Run that test against the actual shared store, since an in-memory mock cannot establish distributed enforcement behavior.
Also define the key's scope and retention. A key built from arbitrary unbounded input can turn the limiter itself into a storage problem. Normalize identities and use an expiry suitable for the policy.
Return a response a client can act on
HTTP 429 Too Many Requests indicates rate limiting. Its response can include Retry-After, and the standard leaves the identification and counting mechanism to the server. See RFC 6585.
For this service, I would return a stable machine-readable code, the policy category, and a retry hint when meaningful:
{
"error": "report_capacity_exceeded",
"message": "This workspace already has the allowed reports running.",
"requestId": "illustrative-request-id"
}
This response sketch avoids pretending that a concurrency slot will definitely open at an exact second. If the server supplies a delay, clients should treat it according to the API contract and their own deadline. A user-facing application should explain whether work was rejected or queued; those are materially different outcomes.
Decide what happens when the limiter fails
There is no universally correct fail-open or fail-closed policy. For a cheap public read, my proposed design might preserve availability with a local emergency cap. For an expensive export, it might reject new work while the shared limiter is unavailable.
Document the fallback per operation. A silent fallback from a shared quota to unlimited local admission can multiply allowed work by the number of replicas. A strict rejection policy can also create an avoidable outage if the protected action was harmless enough to continue.
Rehearse the failure with the limiter store unreachable. Observe admitted work, response latency, and recovery once the store returns. Recovery should not release a huge accumulated queue without regard for current capacity.
Review fairness as a product feature
I would inspect whether one integration can consume every unit available to a workspace and whether an interactive user needs reserved capacity. That is a product decision supported by telemetry, not something a standard algorithm can choose for you.
Tie admission limits to the database connection budget and the service behavior defined in the SLO policy. Limiting request counts alone does not establish that expensive work stays within capacity.
Ship the limiter with an explicit identity, resource budget, burst contract, and store-failure policy. A counter becomes useful protection only when those choices match the work behind the endpoint.