Policy edit and reload
Send a request
Token buckets
Decision log newest first
Runs fully client-side, nothing leaves the page. The same routing, auth, token-bucket, and shaping pipeline as the Rust decide function, on a simulated clock.
Rust · API gateway
Tollgate is a from-scratch API gateway where routing, auth, and rate limiting are a pure pipeline, and every allow or deny carries an explicit reason. A 404 means no route matched, a 401 means the key was missing or unknown, a 403 means it lacked the route's scope, and a 429 means the token bucket was empty and tells the caller how many seconds to wait. Edit the policy below, send a request, and watch it routed, authed, and rate limited with its exact status and reason.
Runs fully client-side, nothing leaves the page. The same routing, auth, token-bucket, and shaping pipeline as the Rust decide function, on a simulated clock.
The whole policy is one function, decide(config, request, now, buckets) -> Decision. It is pure: the only mutable state is the rate limiter passed in, and time enters only through now. Nothing in the policy reads the system clock, so rate limiting is exercised over virtual time and a burst trips 429 and refills correctly without a single real sleep.
$ tollgate check examples/minimal.conf --host localhost --path /api/widgets --key demo-key --repeat 5 [1/5] ALLOW 200 -> 127.0.0.1:9000 (path /api/widgets) [2/5] ALLOW 200 -> 127.0.0.1:9000 (path /api/widgets) [3/5] ALLOW 200 -> 127.0.0.1:9000 (path /api/widgets) [4/5] REJECT 429 reason: rate limit exceeded, retry after 1s [Retry-After: 1] [5/5] REJECT 429 reason: rate limit exceeded, retry after 1s [Retry-After: 1]
The pipeline is four small stages, each short-circuiting on the first rejection. Routing matches by host, method, and path, most specific wins: exact beats a :param template beats a prefix*. Auth requires an API key via X-API-Key or Authorization: Bearer, then checks the route's scope. A per-key token bucket, advanced by the injected clock, returns a 429 with a correct Retry-After. Shaping rewrites headers and path prefixes before forwarding.
Real gateways run this policy in production and are large, config-driven systems. Tollgate is the same policy written as one pure function you can read and test.
Production gateways and proxies with rich plugin ecosystems, driven by large configurations. Powerful, but when a request is denied you are often left tracing which rule fired.
Routes and rate-limits through config directives such as limit_req. Fast and everywhere, but the policy is spread across the config and a denial is terse.
A managed gateway with routing, authorizers, and throttling. Convenient, but it is a hosted service you cannot run locally or unit-test over virtual time.
The whole policy is one pure function, decide(config, request, now, buckets). Every allow or deny carries an explicit status, a human reason, and the headers to send. The clock is injected, so a burst tripping 429 is tested over virtual time with no real sleeps, and there are zero dependencies.
The pieces of the pipeline
Each stage is its own small module and short-circuits on the first rejection. Every outcome names itself.
Match by host, method, and path. Paths can be exact, a :param template, or a prefix*, and the most specific wins: exact beats param beats prefix. No match is a 404.
A route may require an API key via X-API-Key or Authorization: Bearer. Missing or unknown is a 401; a known key without the route's scope is a 403.
A per-key bucket advanced by the injected clock, with a rate and a burst. Empty is a 429 with a correct Retry-After. It falls back to a per-IP bucket for anonymous routes.
Every response is a status plus a human reason plus the headers to send, such as WWW-Authenticate on a 401 and Retry-After on a 429. No silent black-box denial.
Nothing in the policy reads the system clock. Time enters only through now, so rate limiting is exercised over virtual time and bursts are reproducible.
decide is pure: the only mutable state is the rate limiter passed in. Same inputs, same decision, every time. The socket layer is a thin shell around it.
serve a config in front of an upstream, check to dry-run a request and print the decision and reason (repeat it to watch the bucket drain to 429), and print to round-trip a parsed config back out.
decide(config, request, now, buckets) returns Allow with the shaped request or Reject with a status, a reason, and headers. The pipeline is unit-testable with no network at all.
A readable, line-based text format: key lines with scopes, then route { ... } blocks in any order, since routing picks the most specific match. Comments start with #.
The pipeline is exercised over virtual time with no real sleeps, and one integration test drives the real TCP path end to end.
# build and run the pipeline tests, no network needed cargo build cargo test # serve a config in front of an upstream tollgate serve examples/gateway.conf --addr 127.0.0.1:8080 # dry-run a request and watch the bucket drain to 429 tollgate check examples/minimal.conf --host localhost --path /api/widgets --key demo-key --repeat 5