Skip to content
This repository was archived by the owner on Oct 12, 2026. It is now read-only.

feat: rate limit by caller, and decide whose address that is - #11

Merged
jeferson0306 merged 1 commit into
masterfrom
feat/rate-limit
Sep 4, 2026
Merged

jeferson0306 merged 1 commit into
masterfrom
feat/rate-limit

Conversation

@jeferson0306

Copy link
Copy Markdown
Collaborator

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:

Setting Behaviour
TRUSTED_PLATFORM=cloudflare Reads CF-Connecting-IP, which Cloudflare overwrites on every request and a caller cannot forge
TRUSTED_PROXIES=<cidrs> Trusts X-Forwarded-For from those hops only
(unset) Trusts nothing. The limit applies to everyone together — safe and honest rather than silently forgeable — and the service logs a warning saying so

⚠️ One thing to set after merging

Render fronts services with Cloudflare — confirmed against production response headers:

$ curl -sD- https://api-data-validator.onrender.com/health | grep -i '^cf-\|^server'
cf-cache-status: DYNAMIC
server: cloudflare
cf-ray: a35bf2169b5ae3b0-LIS

So set TRUSTED_PLATFORM=cloudflare in 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.

/health is exempt — a throttled monitor reports an outage that is not happening.

An accident caught on the way

go get golang.org/x/time quietly 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, /health survives 260 calls, and a 429 carries Retry-After so the caller is not left guessing. go test ./... -race green.

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.
@jeferson0306
jeferson0306 merged commit 7b239ac into master Sep 4, 2026
1 check passed
@jeferson0306
jeferson0306 deleted the feat/rate-limit branch September 4, 2026 09:29
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant