Open source · MIT

Toll

An API gateway on Slim: keys with quotas, a token bucket, a response cache and a circuit breaker, with a funnel showing where each request stopped.

v1.0.0 You can see where the requests went
Slim 4PHP 8.5PostgreSQL 18PSR-15Tailwind 4
Toll

A request counter explains nothing

Gateway dashboards show how many requests arrived. That number answers nothing: it does not say whether the spike was real traffic or one partner retrying its own 429 in a loop, whether the cache is earning its keep, or why the upstream is quiet: because nobody is calling it, or because the breaker is holding the door shut.

Toll is built around one question: where did the requests go? Every stage of the pipeline reports how many it stopped and how many it let through, and the numbers add up from top to bottom: arrived, minus no key, minus quota, minus served by cache, minus the breaker, reached the upstream.

What is inside

Six middlewares in a fixed order

Route, key, quota, cache, breaker, proxy. The order lives in one file and it is the product: each stage either answers itself or passes the request on, and the response carries a header naming the stage that served it.

A token bucket under a row lock

Refill and spend happen in a single UPDATE ... RETURNING under a row lock, with the clock taken from the database rather than any one app server. A burst of sixty parallel requests is cut exactly at the bucket's capacity, with no double spending.

A cache keyed by the request, not the client

The cache key is method, path and sorted query, never the API key, so one answer serves everyone asking the same thing. A client that already holds the version gets a 304 and no body at all.

A breaker with a single probe

When an upstream falls over, the gateway answers 503 itself and instantly, instead of holding connections until the timeout. Exactly one request becomes the half-open probe, and the transition is an atomic UPDATE rather than a race.

Percentiles without the raw log

Latencies fold into a nine-bucket histogram rolled up per minute, written by the same statement as the log row. The screens read the rollup, so p95 over a day costs the same on a thousand requests and on a million.

Honest refusals

A malformed key is rejected by shape before it reaches the database, a body over the limit never leaves the gateway, and every outcome, refusals included, lands in both the log and the funnel. Clients get Retry-After and X-RateLimit-*, not silence.

Screenshots

The funnel: where each request stopped
The funnel: where each request stopped
Routes: cache, rate, burst and body size
Routes: cache, rate, burst and body size
Keys: quota per minute and per day, tokens left
Keys: quota per minute and per day, tokens left
The log: the outcome of each request and the upstream's own time
The log: the outcome of each request and the upstream's own time
Filtered by outcome: only what the quota turned away
Filtered by outcome: only what the quota turned away
Dark theme
Dark theme

Installation

git clone https://github.com/dripips/toll.git cd toll docker compose up --build

Have a project or idea?

Write to me and we will talk the task through.