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.

One page load under three tolerance settings With zero tolerance only the first of nine page-load requests is admitted; with a tolerance of four intervals five are admitted and the page renders partially; with a tolerance of nine intervals the whole page loads while the sustained rate is unchanged. Nine requests in 40 ms, three settings of tau tau = 0 1 admitted, 8 rejected page fails to render user reloads: 9 more limit creates the load tau = 4 x T 5 admitted, 4 rejected charts missing looks like a bug to the user worst kind of failure: partial tau = 9 x T 9 admitted page renders fully sustained rate unchanged limit is invisible to humans tolerance does not change the rate β€” it changes whether a burst of legitimate work fits

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

python
# 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)
Interaction clusters in a real arrival trace Arrivals group into tight clusters separated by idle gaps: a nine-request page load, a two-request navigation, and a twelve-request export, so the ninety-ninth percentile cluster size rather than the mean rate determines the tolerance. Traffic arrives in clusters, not evenly page load: 9 navigation: 2 export batch: 12 mean rate: 0.4 per second — a number that would size the tolerance far too small size tau from the 99th-percentile cluster, then add 20% of headroom Tolerance by route class at the same sustained rate Read endpoints take a generous burst so page loads pass untouched, write endpoints take a small burst because bursts of writes are usually duplicates, and authentication endpoints take almost none because a burst there is a free run of credential attempts. One rate, three tolerances reads: burst 10 page loads pass whole abuse still bounded users never see a 429 writes: burst 2 covers a double submit blocks bulk creation pairs with idempotency auth: burst 1 no free credential runs idle time buys nothing tolerance as a control a single global tolerance is always wrong for at least one of these

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

python
# 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.