DEV Community

Dinuka Shavinda
Dinuka Shavinda

Posted on

I Rate-Limited an API by Hand. Then I Configured One in a Form Field.

My hotel booking backend has an GET endpoint that has no rate limiting at all, nothing stops a client from calling it as fast as it can respond. When I needed to expose the backend publicly for a separate piece of testing, the only safe option I had was to switch that endpoint off entirely rather than risk it being hit repeatedly by anyone who found the URL.

So when I published a different, read-only endpoint through Bijira, WSO2's API management platform, I wanted to see what a managed rate limit actually does under real traffic, not as an upgrade over something I'd built, but as a look at protection I currently don't have at all.

This isn't a tutorial. It's what I found when I pushed a live API past its configured limit, read the response headers closely, and had to correct my own first conclusion twice along the way.

The setup

I took a real endpoint from the same backend, GET /api/hotels, and published it through Bijira as a managed API proxy. The backend stayed exactly where it already was, on my own machine, reached through a tunnel. Bijira's cloud gateway sat in front of it, handling routing, security, and, for this test, the rate limit.

I set the limit to 5 requests per 60 seconds through Bijira's console. No code, no middleware, just a numeric field and a time-unit dropdown.

Worth flagging up front: this was Bijira's sandbox environment, a -dev host and a sandbox-scoped key. Production deployments could behave differently.

My first attempt taught me the wrong lesson

I sent 10 requests roughly a second apart, waiting for each response before sending the next. Two of those requests hung for 60 seconds each on an unexplained gateway timeout. That stretched my burst across three separate one-minute windows, and nothing ever got rejected.

My first instinct was to blame the spacing, that one request a second apart was too slow to "exercise" the limit. That wasn't it. Ten requests a second apart take about ten seconds, well past a limit of 5, and would still be rejected after the fifth, as long as all ten land in the same clock minute. Start that burst in the last few seconds of a minute, though, and some requests fall into the next one, where the count has already reset. The real problem in my first run wasn't the spacing itself, it was that each request waited for the previous response before sending. When two requests hung for 60 seconds, everything queued behind them got pushed into later minutes, each of which had room again.

The fix wasn't sending requests faster. It was firing on a fixed schedule regardless of how long earlier responses took.

The corrected run

I sent all 10 requests on a fixed 200ms schedule, not waiting for any response before firing the next, starting 2 seconds after a new minute began, close enough to the start of the window that the whole burst landed inside one minute.

req    sent (UTC)                status  took    remaining
1      12:14:02.010Z             200     2171ms  4
2      12:14:02.226Z             200     1954ms  3
3      12:14:02.412Z             200     1762ms  2
4      12:14:02.615Z             200     1623ms  1
5      12:14:02.817Z             200     2055ms  0
6      12:14:03.019Z             429     1020ms  0
7      12:14:03.222Z             429      994ms  0
8      12:14:03.410Z             429      964ms  0
9      12:14:03.613Z             429      995ms  0
10     12:14:03.816Z             429     1035ms  0
Enter fullscreen mode Exit fullscreen mode

Exactly 5 requests through, exactly 5 rejected, cut off precisely at the configured limit. Every 429 carried the same body: "Allowed request limit for the API/Resource, exceeded".

What actually proves the reset behavior

I initially thought a follow-up request, sent well after the burst, proved the limit resets on the clock minute rather than counting 60 seconds from the first request. It doesn't. That follow-up landed 59.993 seconds after request 1, just 7 milliseconds short of a full 60-second window, well within the timing variation already visible across the rest of the log. It couldn't distinguish a rolling window from a clock-aligned one.

The actual evidence is in the x-ratelimit-reset header. At 12:14:02, the header already read 57, counting down to the top of the minute, not to 60 seconds after my first request. Earlier, in the flawed first run, a new count opened at 12:03:10 and returned reset=49, pointing to 12:03:59. A rolling 60-second window would have read 60. Both data points agree: the count is tied to the clock minute, not to whenever the first request happened to land.

Proving where the rejection actually happens

Response timing alone can't prove where a request was stopped, so I needed a measurement that doesn't depend on timing. My backend sits behind a Cloudflare tunnel, and the tunnel exposes its own request counter, a number that only increases when a request actually reaches my machine. If Bijira rejects a request at the gateway, the counter doesn't move. If the request reaches my backend and gets rejected there instead, the counter moves anyway.

I read the counter before a burst and again once every response had returned.

Before                            24
After (all responses returned)    28
After (+10s, stable)              28
Change                            +4
Enter fullscreen mode Exit fullscreen mode

A later burst, at 14:33, produced this: 5 requests allowed, 5 rejected, same shape as before, except this run also hit one unexplained upstream timeout (504) on request 3, a failure mode that showed up intermittently throughout this work and that I never fully root-caused. That timeout still consumed one of the five allowed slots, the rate limiter counted it as a used request even though it never completed. Of the 5 requests the limiter allowed through, 4 succeeded and reached my backend; the remaining one, request 3, timed out before it reached the tunnel connector on my machine. The counter rose by exactly 4. Other traffic could only have pushed that number up, never down, so a result of exactly 4 leaves no room for the rejected requests to have reached my backend.

The 5 requests Bijira rejected with a 429 added nothing to that count. My backend is only reachable through the tunnel, so if those requests had reached it, the counter would have risen by 9, not 4. It didn't. Bijira rejected them at the gateway; they never reached my tunnel.

That's the actual answer to the question I started with. My AI search endpoint, sitting unprotected on the same backend, would let every one of those 10 requests straight through to my code, and, once that endpoint is configured with a live API key, straight through to a paid model behind it. A request Bijira rejects never gets that far. It never arrives.

The form field doesn't warn you when it stops working

One thing this project taught me that the clean numbers above don't show: after I changed the proxy's backend URL partway through this work, the rate limit silently stopped applying. A full burst of 10 requests came back 10 × 200, with none of the x-ratelimit-* headers present, a check a few minutes later still showed none. The limit only came back after I went into Bijira and re-applied the policy to the live deployment. Nothing warned me it had dropped off. The only reason I noticed is that I'd gotten in the habit of checking for the rate-limit headers on a single request before trusting a full burst test, a habit this same project had already taught me the hard way, once with a database connection and once with a stale tunnel URL.

Configuring the limit took a form field. Losing it took nothing at all, no error, no notification, just a deployment that quietly stopped enforcing what I'd set.

What's still genuinely open

I don't know whether the 5-per-minute limit is shared across every consumer of this API or scoped to my individual key. Bijira's error message says "limit for the API/Resource," which is ambiguous on exactly this point. If that limit is shared across every consumer, it protects my bill but not my users: one client could exhaust the quota and lock everyone else out of the feature. That distinction is worth confirming before relying on this in anything beyond a test.

The unexplained 504s are the other loose end. They showed up intermittently across this whole project, never predictably enough to isolate a cause, and this run's timeout is the first time I could confirm one failed before it reached the tunnel connector on my machine, not inside my own code. I'm noting it rather than explaining it.

What this actually settles

Bijira's limit enforced exactly what I configured, its reset is tied to the clock minute rather than a rolling window, setting it up took a form field instead of writing and testing rejection logic myself, and, now confirmed rather than inferred, requests it rejects never reach my backend at all.

That last point is the one that matters most given where I started. My AI search endpoint has no protection of its own, so there is no ceiling on how many paid model calls a single client can trigger. A gateway limit wouldn't make unwanted traffic free. Whatever gets under the limit still reaches the model. But it turns "unbounded" into a number I chose, and everything over that number is turned away before my server, or my model bill, ever sees it.

Top comments (0)