A fleet-wide overnight sync can slam an MRO gateway with tens of thousands of logbook records in a burst, and a gateway that buckles under that load risks dropping airworthiness data on the floor. The fix is not to reject bulk traffic but to shape it: admit what the backend can durably persist and make the client wait for the rest. This page covers token-bucket and sliding-window rate limiting at the gateway edge, a control layer within the Secure API Gateway Architecture that keeps continuing-airworthiness records — the kind mandated by EASA Part-M M.A.305 — from being silently lost under peak load.
Prerequisites & inputs
Rate limiting operates on authenticated, identified traffic. Each incoming request already carries a verified client identity (from the certificate and token layers) and a scope, so the limiter can budget per client rather than globally — the reliability feed for D-AIMA gets its own bucket separate from a subcontractor’s APU-overhaul exporter. The limiter needs three tuned inputs per client: a sustained rate (records per second the backend can commit), a burst allowance (how far above sustained a short spike may go), and a window length for the counting strategy.
Critically, the limiter’s job is admission, not deletion. A request that exceeds the budget is answered with 429 Too Many Requests and a Retry-After hint, never dropped. That contract lets a well-behaved client pace itself and retry, which is exactly the cooperative behaviour a bulk uploader should already implement. The gateway limiter here is the coarse first defence; the fine-grained flow control inside a worker pool is handled downstream by Backpressure and Rate Limiting in Asyncio Ingestion Pipelines.
Shaping the load
Step-by-step implementation
- Choose the model per traffic shape. A token bucket absorbs bursts gracefully: tokens refill at the sustained rate and a full bucket lets a short spike through. A sliding window enforces a hard ceiling over a rolling interval and is better where a strict cap must never be exceeded. Bulk uploads usually want the bucket for its burst tolerance.
- Refill lazily. Do not run a background timer per client. On each request compute elapsed time since the last refill and add
elapsed * ratetokens, capped at capacity. This is O(1) per request and needs no scheduler. - Charge per unit of work. A request carrying 500 records should cost more than one carrying five. Charge tokens proportional to batch size so a single fat request cannot starve the backend.
- Return an honest
Retry-After. When the bucket is empty, compute how long until enough tokens accrue and hand that back so the client waits the right amount instead of hammering in a tight loop. - Emit metrics. Every admit and throttle decision is logged and counted so operators can see when a client is chronically over budget and needs its sustained rate renegotiated rather than throttled nightly.
The following limiter combines a lazily-refilled token bucket with a sliding-window ceiling and returns a structured decision the gateway can turn into a 200 or a 429.
from __future__ import annotations
import logging
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Deque
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("mro.gateway.ratelimit")
@dataclass
class Decision:
admitted: bool
retry_after_s: float
reason: str
@dataclass
class ClientLimiter:
"""Per-client token bucket plus a sliding-window hard ceiling.
Continuing-airworthiness records must not be lost under load; see
EASA Part-M M.A.305 (aircraft continuing-airworthiness record).
"""
rate_per_s: float # sustained refill rate
capacity: float # burst allowance
window_s: float # sliding-window length
window_max: int # max records admitted per window
_tokens: float = field(default=0.0)
_last_refill: float = field(default_factory=time.monotonic)
_events: Deque[tuple[float, int]] = field(default_factory=deque)
def __post_init__(self) -> None:
self._tokens = self.capacity
def _refill(self, now: float) -> None:
elapsed = now - self._last_refill
self._tokens = min(self.capacity, self._tokens + elapsed * self.rate_per_s)
self._last_refill = now
def _window_count(self, now: float) -> int:
while self._events and now - self._events[0][0] > self.window_s:
self._events.popleft()
return sum(count for _, count in self._events)
def admit(self, registration: str, record_count: int) -> Decision:
now = time.monotonic()
self._refill(now)
if self._window_count(now) + record_count > self.window_max:
oldest = self._events[0][0] if self._events else now
wait = max(0.0, self.window_s - (now - oldest))
logger.warning("THROTTLED_WINDOW reg=%s n=%d wait=%.2fs", registration, record_count, wait)
return Decision(False, round(wait, 2), "sliding_window_exceeded")
if self._tokens < record_count:
deficit = record_count - self._tokens
wait = deficit / self.rate_per_s
logger.warning("THROTTLED_BUCKET reg=%s n=%d wait=%.2fs", registration, record_count, wait)
return Decision(False, round(wait, 2), "bucket_empty")
self._tokens -= record_count
self._events.append((now, record_count))
logger.info("ADMITTED reg=%s n=%d tokens_left=%.1f", registration, record_count, self._tokens)
return Decision(True, 0.0, "ok")
Worked example
The exporter for D-AIMA is configured with rate_per_s=50, capacity=200, window_s=1.0, and window_max=120. At the start of an overnight run the bucket is full at 200 tokens. The first request carries 150 ATA 49 APU-overhaul records for part APS3200-77; the bucket has room, 150 tokens are charged, and the window count becomes 150 — but wait, that exceeds window_max of 120, so the sliding window throttles it with Retry-After covering the remaining window. The client resubmits in two batches of 75.
The first batch of 75 is admitted (ADMITTED reg=D-AIMA n=75 tokens_left=125) and recorded in the window. The second batch of 75 pushes the window count to 150, over the 120 ceiling, so it is answered THROTTLED_WINDOW with a sub-second Retry-After. The client waits, the window slides, and the second batch flows a moment later. No APU record is ever discarded; each is either admitted or explicitly deferred with a precise wait.
Edge cases & compliance gotchas
- Retries must not double-count. A client that retries after a
429must send the same records, not append them again. Key each batch with an idempotency token tied to the source document hash so a replayed batch is deduplicated rather than counted twice against the window. - Distributed gateways drift. Two gateway replicas each holding a local bucket admit twice the intended rate. For a hard fleet-wide ceiling, back the counters with a shared store (a Redis sliding window, for example); the in-process limiter here is correct only for a single node or as a per-node sub-budget.
monotonicversus wall clock. Refill math must use a monotonic clock. If you compute elapsed time from the wall clock and an NTP step jumps it backwards,elapsedgoes negative and the bucket stalls or over-fills — a subtle way to lose an upload window.- Throttling is not authentication. A limiter shapes volume; it does not decide who may push. Enforce identity first with Mutual TLS for Aviation Maintenance Data APIs so the budget is charged to a proven client and an unauthenticated flood is rejected at the handshake, never reaching the bucket at all.
Effective rate limiting turns a destructive traffic spike into an orderly queue. By pacing bulk uploads with a burst-tolerant bucket, capping them with a rolling window, and always answering overflow with an honest deferral instead of a dropped record, the gateway keeps every airworthiness entry accounted for while protecting the backend it must never overwhelm.