Burst Tolerance Tuning with GCRA
The emission interval is fixed by the rate you advertise; the tolerance is the only free parameter, and it decides whether your limit feels invisible or infuriating to a legitimate client. Tuning it is a measurement exercise, not a matter of taste, and it extends the parameter reference in the GCRA and virtual scheduling guide with the method for choosing an actual number.
The problem in concrete numbers
A dashboard fires 9 requests when it loads: user, account, two widget queries, four charts, and a feature-flag fetch. Your published limit is 60 requests per minute, so T = 1,000 ms. With Ο = 0, the dashboardβs second request β arriving 4 ms after the first β is rejected. The user sees a half-rendered page and reloads, producing another 9 requests. Your limiter has converted a compliant client into a retry loop.
With Ο = 9,000 ms (a burst of 9), the whole page loads, and the client then needs 9 seconds of quiet before it has the same slack again β which it has, because a human reads the dashboard for longer than that. The limit is enforced exactly as advertised over any minute-long window, and no user ever notices it.
Decision matrix: tolerance by client type
| Client pattern | Unit of intent | Suggested burst |
Reasoning |
|---|---|---|---|
| Single-page app | Requests per view | 1.5 Γ requests per view | Covers a view plus a stray prefetch |
| Mobile app on resume | Requests in the resume storm | Full resume set | A cold foreground fires everything at once |
| Server-to-server sync | Batch size | Batch size, capped | One batch should not need pacing mid-flight |
| Nightly export job | Page size Γ parallelism | 2 Γ parallelism | The job is throughput-bound, not latency-bound |
| Webhook receiver (you as client) | Events per spike | Small (2β5) | Steady pacing beats bursts; the far side is limited too |
| Anonymous scraping-prone endpoint | β | 2β3 | Just enough for a browser, not enough to be worth abusing |
| Write endpoint with side effects | 1 | 1β2 | Bursts of writes are rarely legitimate and often duplicates |
Note the last two rows: tolerance is also a security parameter. A generous Ο on a login endpoint hands an attacker a free burst of credential attempts every idle period, so keep it near zero there even when your read endpoints are generous.
Step-by-step: deriving tau from data
# tune_tau.py β derive burst from a real arrival trace.
import math
from collections import defaultdict
GAP_MS = 500 # arrivals closer than this belong to one interaction
def clusters(timestamps):
"""Sizes of consecutive-arrival clusters for one identity."""
sizes, current, last = [], 0, None
for t in sorted(timestamps):
if last is not None and t - last > GAP_MS:
sizes.append(current); current = 0
current += 1; last = t
if current: sizes.append(current)
return sizes
def percentile(values, p):
if not values: return 0
values = sorted(values)
return values[min(len(values) - 1, int(round(p / 100 * (len(values) - 1))))]
def recommend(trace_by_identity, limit, period_ms):
all_sizes = []
for identity, stamps in trace_by_identity.items():
all_sizes.extend(clusters(stamps))
p99 = percentile(all_sizes, 99)
burst = math.ceil(p99 * 1.2)
T = period_ms / limit
tau = burst * T
print(f"p99 interaction size : {p99}")
print(f"recommended burst : {burst}")
print(f"T : {T:.0f} ms")
print(f"tau : {tau:.0f} ms ({tau / period_ms:.0%} of the window)")
if tau > period_ms:
print("WARNING: tolerance exceeds the advertised window β the rate is unenforceable "
"inside one window; reduce burst or lengthen the period")
return burst, tau
# Example: 60/min published, arrivals collected from access logs.
recommend(load_trace("access.log"), limit=60, period_ms=60_000)Gotchas and edge cases
- Tolerance larger than the window. With
Ο β₯ period, a client can spend an entire windowβs budget instantly and the advertised rate no longer bounds anything inside one window. KeepΟbelow about half the period. - Sizing from the mean. Average request rate says nothing about bunching. A client averaging 0.4 requests per second still needs a burst of 12 for its export job.
- One tolerance for all endpoints. A read-heavy dashboard and a password-reset endpoint have opposite requirements. Set
Οper route class, not globally. - Forgetting the mobile resume storm. Apps returning to the foreground refresh everything at once; a tolerance sized for steady use rejects half of it and users see errors on the screen they just opened.
- Tolerance that hides a broken client. If
Οis large enough that a runaway loop never trips the limit, you have lost your early warning. Watch the near-limit rate as well as the rejection rate. - Changing tau without versioning the key. Existing schedules were computed under the old tolerance; the transition period behaves as neither setting. Version the key prefix on material changes.
Verification and testing
# Replay a real trace against candidate parameters before enforcing anything.
def simulate(trace, limit, period_ms, burst):
T = period_ms / limit
tau = burst * T
tat, admitted, denied, worst_wait = 0.0, 0, 0, 0.0
for now in trace: # ms timestamps, ascending
if now < tat - tau:
denied += 1
worst_wait = max(worst_wait, (tat - tau) - now)
else:
tat = max(now, tat) + T
admitted += 1
return {
"admitted": admitted, "denied": denied,
"reject_rate": round(denied / (admitted + denied), 4),
"worst_wait_s": round(worst_wait / 1000, 1),
}
for burst in (0, 4, 9, 20):
print(burst, simulate(real_trace, limit=60, period_ms=60_000, burst=burst))
# burst 0 -> reject_rate 0.41 worst_wait_s 8.0 (rejects normal page loads)
# burst 4 -> reject_rate 0.12 worst_wait_s 5.0 (partial pages)
# burst 9 -> reject_rate 0.004 worst_wait_s 2.0 (only the export job waits)
# burst 20 -> reject_rate 0.000 worst_wait_s 0.0 (no longer constrains anything)Pick the smallest burst whose rejection rate against real traffic is close to zero. Anything larger stops constraining abuse; anything smaller starts rejecting the clients you built the API for.
Frequently Asked Questions
Does a larger tolerance raise the rate limit?
No. The sustained rate is fixed by the emission interval. Tolerance only controls how much a client may run ahead of schedule before being paced, so a bigger tolerance permits a longer burst followed by a proportionally longer wait β the average over a window is unchanged.
What is a safe default if I have no measurements?
Ten emission intervals for read endpoints, two for writes, and one or two for authentication paths. Then measure: the first week of shadow-mode logs will tell you far more than any default, and the numbers usually move in the direction of a larger read burst.
Should burst be published alongside the rate?
Yes. A client that knows both numbers can pace itself precisely and will almost never be rejected; a client that knows only the rate must discover the burst by hitting it. Publishing both costs nothing and removes an entire class of support question.
Can tolerance differ per plan?
Yes, and it often should: an enterprise integration running batch jobs needs a larger burst than a free-tier browser client, even at the same sustained rate. Store burst next to rate in the plan definition and pass both into the limiter.
Related
- GCRA & Virtual Scheduling β the parent guide defining the emission interval and tolerance.
- Implementing GCRA in Redis Lua β where these numbers are actually applied.
- Choosing Window Size for API Rate Limits β the equivalent sizing question for window algorithms.
- SDK & Client-Side Throttling β how a client uses the published burst to avoid rejection entirely.