This repository was archived by the owner on Oct 12, 2026. It is now read-only.
Repository navigation
feat: rate limit by caller, and decide whose address that is - #11
Merged
Merged
Conversation
A public endpoint with no limit is an invitation, and the benchmarks made the invitation cheaper to accept: at 522ns a request, serving abuse costs the host almost nothing per call and everything in aggregate. A token bucket per caller, in memory. Not in Redis — protecting work that takes half a microsecond with a network round trip would cost five orders of magnitude more than the thing being protected. Idle buckets are swept, or the map grows for the life of the process. The interesting part is not the limiter, it is deciding whose address it counts. Behind a platform proxy the socket address belongs to the proxy, so with no configuration every caller shares one bucket and the limit is global. The obvious fix is to trust X-Forwarded-For, and it is worse: on a public host that header is written by whoever is calling, so a limit keyed on it is a limit anyone steps around by changing a string. Gin trusts all private ranges by default, which is exactly that mistake with a reassuring name. So it is explicit. TRUSTED_PLATFORM=cloudflare reads CF-Connecting-IP, which Cloudflare overwrites on every request and a caller cannot forge — and Render fronts services with Cloudflare, confirmed against production response headers, which makes it the right setting there. TRUSTED_PROXIES takes CIDRs for anything else. Unset, nothing is trusted and the limit applies to everyone together: safe and honest rather than silently forgeable, and the service logs a warning saying so. Per-address limiting carries a known cost, and it is the one a load test against a gateway of mine surfaced: everyone behind one NAT is throttled as a single abusive caller. The fix is an authenticated identity, which this service does not have, so the limit is generous instead — high enough that a shared office address is not punished, low enough that a script is. /health is exempt. A throttled monitor reports an outage that is not happening. golang.org/x/time is pinned to a release that keeps the Go directive where it was; go get had quietly moved it from 1.22 to 1.25, and a toolchain bump on a live deployment is not something to acquire as a side effect of adding a dependency.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A public endpoint with no limit is an invitation, and #10's benchmarks made the invitation cheaper to accept: at 522 ns a request, serving abuse costs almost nothing per call and everything in aggregate.
A token bucket per caller, held in memory. Not in Redis — protecting work that takes half a microsecond with a network round trip would cost five orders of magnitude more than the thing being protected. Idle buckets are swept, or the map grows for the life of the process.
The interesting part is not the limiter
It is deciding whose address it counts.
Behind a platform proxy the socket address belongs to the proxy, so with no configuration every caller shares one bucket and the limit is global. The obvious fix is to trust
X-Forwarded-For— and it is worse: on a public host that header is written by whoever is calling, so a limit keyed on it is a limit anyone steps around by changing a string. Gin trusts all private ranges by default, which is exactly that mistake wearing a reassuring name.So it is explicit:
TRUSTED_PLATFORM=cloudflareCF-Connecting-IP, which Cloudflare overwrites on every request and a caller cannot forgeTRUSTED_PROXIES=<cidrs>X-Forwarded-Forfrom those hops onlyRender fronts services with Cloudflare — confirmed against production response headers:
So set
TRUSTED_PLATFORM=cloudflarein the Render dashboard. Without it the limiter works, but keys every request to the same proxy address and throttles all callers together.A known cost, deliberately accepted
Per-address limiting throttles everyone behind one NAT — an office, a mobile carrier — as though they were a single abusive caller. That is not a theoretical objection: it is the finding a load test against a gateway of mine surfaced, precisely because unit and integration tests never generate concurrent traffic from distinct clients.
The real fix is keying on an authenticated identity, which this service does not have. So the limit is generous instead: 20 rps with a burst of 60, high enough that a shared office address is not punished and low enough that a script is. Both are configurable.
/healthis exempt — a throttled monitor reports an outage that is not happening.An accident caught on the way
go get golang.org/x/timequietly moved the Go directive from 1.22.5 to 1.25.0. A toolchain bump on a live deployment is not something to acquire as a side effect of adding a dependency, so the dependency is pinned to a release that leaves it alone.Tests
Four cases: the burst is honoured and then throttled, two addresses are limited independently,
/healthsurvives 260 calls, and a 429 carriesRetry-Afterso the caller is not left guessing.go test ./... -racegreen.