Skip to content

P0: Add per-IP request rate limiting (#166) - #185

Merged
dkijania merged 5 commits into
mainfrom
feat/rate-limit
Aug 26, 2026
Merged

P0: Add per-IP request rate limiting (#166)#185
dkijania merged 5 commits into
mainfrom
feat/rate-limit

Conversation

@dkijania

Copy link
Copy Markdown
Contributor

What & why

Part of the production-readiness epic (#163). Closes #166.

A public GraphQL endpoint with no throttle lets a single client monopolise the server and the backing Postgres. This adds a global, per-client-IP rate limiter that runs on every request before GraphQL parsing, rejecting over-limit traffic with HTTP 429 as cheaply as possible.

Env var Default Meaning
RATE_LIMIT_MAX 600 Requests per client IP per window; 0 disables
RATE_LIMIT_WINDOW_MS 60000 Window length in ms

Design

  • Client IP from X-Forwarded-For (first hop) → X-Real-IP → socket address → shared unknown bucket. Run behind a proxy that sets X-Forwarded-For for correct per-client identification (documented).
  • Fixed-window in-memory counter, per-instance: with N replicas the effective limit is ~N × RATE_LIMIT_MAX. A shared store (Redis) for exact cross-replica limits is left as deployment hardening and noted in the docs.
  • Health checks (/healthcheck) are never throttled.
  • 429 responses carry Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining.

Why not @envelop/rate-limiter

That library is directive-based: limits are baked into the schema per field and it's per-field rather than a global per-IP bucket, plus it requires a schema/codegen change. A small custom plugin gives a true global per-IP DoS bucket that's env-tunable with no schema impact. (Discussed and chosen during implementation.)

Testing

  • npm run build — clean
  • npm run test:unit — all pass (config parsing; windowing/reset/prune with an injected clock; end-to-end through Yoga proving the (max+1)th request returns 429 and other clients are unaffected)
  • npm run lint — clean
  • npx prettier --debug-check . — exit 0

No new runtime dependency.

🤖 Generated with Claude Code

@dkijania dkijania added production-readiness Work toward making the API production-ready / publicly available P0 Blocker for public availability labels Jun 28, 2026
@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Nice work — this lands the shape we want for the mina-explorer client: the 600 req / 60s default comfortably absorbs the Explorer's per-page fallback bursts (full→basic→minimal + per-block detail is well under 600/min), /healthcheck is exempt so liveness probes won't flap, RATE_LIMIT_MAX=0 is a clean off switch, and the 429 body is safely distinct from the Cannot query field string the Explorer keys on for fallback. 👍

One hardening worth doing before we call #166 done: X-Forwarded-For is trusted unconditionally, so a single abusive source can rotate the header to mint a fresh bucket per request and slip right past the limit — the exact thing #166 asks us to stop — and each forged value also adds a Map entry that only clears at window end (spoofable memory growth).

The subtlety is that honoring XFF is also what keeps NAT'd / LB-fronted Explorer users in their own buckets instead of collapsing onto one shared IP — so the fix isn't to drop XFF, it's to bound the trust with a hop-count knob and fall back to the socket address:

// TRUST_PROXY = number of trusted proxy hops in front of us.
// Behind an LB/ingress (our deployment): set it to the hop count so real client IPs are used
// (per-user Explorer buckets preserved). Set 0 on a directly-exposed server to ignore XFF.
function clientId(request, serverContext) {
  const hops = Number(process.env.TRUST_PROXY ?? 0);
  if (hops > 0) {
    const xff = request.headers.get('x-forwarded-for')?.split(',').map(s => s.trim()).filter(Boolean) ?? [];
    const ip = xff[xff.length - hops]; // count from the right → ignores attacker-prepended entries
    if (ip) return ip;
  }
  const ctx = serverContext;
  return ctx?.req?.socket?.remoteAddress ?? ctx?.socket?.remoteAddress ?? 'unknown';
}

Two smaller, non-blocking notes: (1) worth confirming the short-circuited 429 still picks up Access-Control-Allow-Origin from Yoga's useCORS (it runs on onResponse, so it should) — since the Explorer is cross-origin, an ACAO-less 429 would surface as an opaque CORS error instead of a clean 429; and (2) a quick test asserting /healthcheck never 429s would lock in the liveness exemption. Great addition overall.

dkijania added a commit that referenced this pull request Jul 16, 2026
X-Forwarded-For was trusted unconditionally and read left-to-right, so a
single source could rotate the header to mint a fresh bucket per request
and bypass the limit entirely — the abuse this plugin exists to stop —
while each forged value also added a Map entry that only cleared at
window end.

Dropping the header isn't the fix: honouring it is what keeps NAT'd and
LB-fronted clients in their own buckets rather than collapsing onto one
address. Instead TRUST_PROXY declares how many proxy hops sit in front of
the API, and the client is read as that many entries from the right — the
portion our own proxies appended — so prepended values are ignored. It
defaults to 0, which ignores forwarding headers and keys on the socket
address: the safe reading for a directly-exposed server. X-Real-IP is
gated the same way, and a chain shorter than the hop count falls back to
the socket rather than trusting a caller-supplied entry.

Also pins two contracts the limiter must not regress: the healthcheck is
never throttled, and the short-circuited 429 still carries CORS headers
(verified — yoga's CORS runs on onResponse), so cross-origin clients get
a readable 429 instead of an opaque CORS error.

Addresses review feedback on #185.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dkijania

Copy link
Copy Markdown
Contributor Author

Thanks @SanabriaRusso — the XFF bypass was real and is fixed in 3932d3e / 9881c02. The framing that the fix is to bound the trust rather than drop the header was the useful part: dropping it would have collapsed NAT'd and LB-fronted clients onto one bucket, which is its own availability problem.

TRUST_PROXY hop count, counted from the right. Default 0 ignores forwarding headers entirely and keys on the socket address — the safe reading for a directly-exposed server. Set it to the hop count behind an LB and the client is read as the Nth entry from the right, so anything the caller prepended is ignored. X-Real-IP is gated the same way (it was equally spoofable), and a chain shorter than the hop count falls back to the socket rather than trusting a caller-supplied entry.

One deviation from your snippet: I threaded it through resolveRateLimitConfig instead of reading process.env inline, to keep the module's injectable-env convention — that's what let the bypass itself be tested rather than just reasoned about:

// one LB in front, so only the last entry is ours; varying the prepended value must not help
assert.strictEqual((await request('a.a.a.a, 7.7.7.7')).status, 200);
assert.strictEqual((await request('b.b.b.b, 7.7.7.7')).status, 200);
assert.strictEqual((await request('c.c.c.c, 7.7.7.7')).status, 429);

Your ACAO question — confirmed, no fix needed. Your reasoning was right: yoga's CORS runs on onResponse, so it applies to the short-circuited 429. Verified against a yoga instance configured the way production is:

allowed  -> 200 ACAO: "https://explorer.example.com"
limited  -> 429 ACAO: "https://explorer.example.com"

Pinned as a test, since it's the kind of thing a plugin-order change could quietly break. /healthcheck never 429s is now covered too.

One thing worth a second opinion: TRUST_PROXY=0 is secure-by-default but wrong for our own topology — the archives sit behind an LB, so an unset value buckets every client together. Rather than default to 1 (which would reintroduce the bypass for directly-exposed deployments), it warns once on the first forwarded request when TRUST_PROXY=0, so a misconfigured deploy says so instead of just throttling oddly. Same spirit as the startup warning you suggested for #184's CORS default. Shout if you'd rather it were louder — or an outright boot failure.

On /metrics and the exemption list (from your #191 note): leaving that for the #185#191 reconcile rather than exempting a path that 404s today — exempting a nonexistent route now would just be an unthrottled hole. Worth deciding there whether the fix is the exemption list or plugin ordering, since the merge plan currently assumes ordering and your note assumes the list; we should land one, not half of each.

dkijania added a commit that referenced this pull request Jul 17, 2026
Three corrections, all of which would have misled operators:

CORS. The checklist told operators to set an allowlist "or leave unset —
not *", which would block every cross-origin browser client, the
mina-explorer included, with no server-side symptom. For a public
read-only API over already-public data, CORS_ORIGIN=* is the correct
setting rather than a lapse: CORS constrains browsers, not curl, so it
is not an access control. Adds a section making the choice explicit and
notes these controls arrive in 1.0.0 — on 0.0.x, CORS_ORIGIN defaults to
'*', so the protections table describes a version most operators are not
yet running.

TRUST_PROXY. The doc described X-Forwarded-For as read "first hop",
which is the behaviour removed in #185 as a rate-limit bypass. Documents
the hop-count model and the deny-by-default reading instead.

Read replicas. The README claimed the server "fans queries across"
multiple PG_CONN hosts. It does not: postgres.js scopes hostIndex per
Connection (src/connection.js:89), so every pooled connection starts at
host[0] and only advances on failure — failover, not fan-out. As written
it promised read scaling that adding replicas cannot deliver.

Addresses review feedback on #186.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Jul 17, 2026
…Y row

Scaling told operators to "add read replicas and point PG_CONN at them
before scaling the API further", which reads as added read capacity. It
isn't: postgres.js scopes hostIndex per Connection, so every pooled
connection starts at host[0] and only advances on failure. Extra hosts
buy redundancy, not throughput — real read scaling needs a balancer in
front of Postgres. The failover section now says so plainly rather than
leaving "connects to an available host" open to the throughput reading.

Adds a version scope note. Nearly everything the runbook says to observe
or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are
no-ops, and SIGTERM skips the drain. A runbook that misdirects mid-
incident is worse than no runbook, and the published image today is
0.0.6. Scoping by version rather than by in-flight PR numbers keeps the
note true after the merge train lands.

Splits the 429 incident row: after #185, mass 429s across unrelated
clients most likely means TRUST_PROXY is unset behind a gateway,
collapsing every client into one bucket — a different fix from a single
client exceeding the limit.

Addresses review feedback on #197.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Verdict: NOT MERGEABLE (blocking) 🚫

The XFF hardening from the first pass is genuinely fixed and the hop-count-from-the-right logic is correct. One blocker remains, and it is the interaction between the two defaults rather than either one alone.

What I checked

  • OPTIONS preflight is exempt — verified by running it, not by reasoning. useCORS is registered at node_modules/graphql-yoga/cjs/server.js:171, before ...(options?.plugins ?? []) at :215, and its endResponse triggers stopEarly() (node_modules/@whatwg-node/server/cjs/createServerAdapter.js:64-67utils.js:350-355), which halts the remaining onRequest hooks. I built a yoga instance mirroring src/server/server.ts:14-28 with a max=2 limiter and fired 5 preflights: all 204 with Access-Control-Allow-Origin, and the limiter's counter saw zero of them; the 3rd POST still got its 429 with ACAO intact. HARD CONSTRAINT Add a Dockerfile to build and run the server #2 satisfied.
  • 429 body is safe for both consumers. mina-explorer-api/app/upstream/graphql.py:78-85"Too many requests. Please retry later." matches neither SCHEMA_ERROR_MARKERS (:32-36) nor UNAVAILABLE_ERROR_MARKERS (:40-43), so it classifies as a plain UpstreamError, not UpstreamSchemaError. No spurious FULL→BASIC→MINIMAL tier demotion, and no inBestChain / Cannot query field string collision. mina-explorer/src/services/api/client.ts:52-69 re-parses the non-2xx body and surfaces GraphQL error: Too many requests. Please retry later. — a readable message, not an opaque network error. HARD CONSTRAINT Add Actions resolver support #1 clean.
  • No persistent corruption from a 429. mina-explorer/src/services/api/blocks.ts:506-507 only calls markBestChainFilterUnsupported when isBestChainFilterError(filteredError), so a 429 cannot poison the cached capability state.
  • Memory is bounded. createRateLimiter.prune plus the unref'd setInterval(…, windowMs) is fine, and reading XFF from the right closes the spoofed-key growth vector the first pass flagged. Good.
  • Multi-replica claim is honestdocs/getting-started.md says ~N × RATE_LIMIT_MAX, which is what an in-process map gives you.

Blocker: TRUST_PROXY=0 + RATE_LIMIT_MAX=600 is a 10 req/s ceiling for the entire endpoint

What breaks. On the deployed defaults, every request to archive-node-api.gcp.o1test.net arrives from the LB/ingress socket address, so clientId returns one value for the whole world (src/server/rate-limit.ts, clientId ~L142: if (trustProxy <= 0) return socketAddress(serverContext);, with RATE_LIMIT_DEFAULTS.trustProxy = 0 at ~L36-40). Effective budget: 600 req/min shared globally.

Measured against real demand:

  • mina-explorer-api/app/config.py:155indexer_backfill_rps: float = 4.0240 req/min from one pod, 40% of the global budget before a single browser user.
  • mina-explorer-api/app/observability.py ReadinessProbe (cache_ttl: float = 5.0) → up to 12 more req/min per pod.
  • Every mina-explorer browser user worldwide shares the same remaining ~350 req/min.

And it degrades non-linearly once tripped: fetchBlocksListWithFallback (mina-explorer/src/services/api/blocks.ts:497-517) catches any error and retries the next field set, so one 429 becomes up to 6 requests per page view (3 filtered + 3 unfiltered), pushing the shared bucket further under.

This is exactly the brief's named footgun — "a default rate limit below the ~4 req/s + browser burst budget". Note this does not contradict @SanabriaRusso's "600/60s comfortably absorbs the Explorer's per-page bursts": that holds per client, and TRUST_PROXY=0 is precisely what stops it from being per client.

The warning does not save it, and the doc's suggested value makes it worse. docs/getting-started.md says "1 for a single ingress/LB". GCP's external Application Load Balancer appends two entries — "The IP address of the client that connects to the load balancer" then "The IP address of the load balancer's forwarding rule". So TRUST_PROXY=1 on *.gcp.o1test.net keys on the forwarding-rule IP — one address — reproducing the identical global collapse, except now warnedAboutProxy is pre-set to true (useRateLimit, ~L186) so nothing warns at all. The correct value there is 2, plus one per additional in-cluster hop.

Fix. There is no default that is right for both topologies: socket-keying behind an LB is a self-DoS, and trusting XFF without a hop count is the bypass you just fixed. So don't pick one — make the limiter refuse to engage until the operator states the topology. TRUST_PROXY unset ⇒ limiter off (identical to today's behaviour, so no regression and no crash-loop); TRUST_PROXY=0 ⇒ socket keying; TRUST_PROXY=N ⇒ hop keying. This is not attacker-toggleable — the decision is made once from env at boot, never from a request header.

src/server/rate-limit.ts

   trustProxy: number;
+  /**
+   * Whether TRUST_PROXY was explicitly and validly set. No default is correct
+   * for both topologies — keying on the socket behind an LB puts every client
+   * in one bucket (self-DoS), trusting X-Forwarded-For without a hop count
+   * lets any caller mint a fresh bucket (bypass) — so rather than guess, the
+   * limiter stays inert until the operator says which one this deployment is.
+   */
+  trustProxyConfigured: boolean;
 }

 const RATE_LIMIT_DEFAULTS: RateLimitConfig = {
   max: 600,
   windowMs: 60_000,
   trustProxy: 0,
+  trustProxyConfigured: false,
 };
+/**
+ * Parse TRUST_PROXY strictly. Unlike `intFromEnv`, a missing or malformed value
+ * yields `undefined` ("not configured") rather than silently becoming `0`, so a
+ * typo cannot be mistaken for a deliberate directly-exposed deployment.
+ */
+function hopsFromEnv(value: string | undefined): number | undefined {
+  if (value === undefined || value.trim() === '') return undefined;
+  const parsed = Number(value);
+  if (!Number.isInteger(parsed) || parsed < 0) return undefined;
+  return parsed;
+}
+
 function resolveRateLimitConfig(env: EnvSource = process.env): RateLimitConfig {
+  const hops = hopsFromEnv(env.TRUST_PROXY);
   return {
     max: intFromEnv(env.RATE_LIMIT_MAX, RATE_LIMIT_DEFAULTS.max),
     windowMs: Math.max(
       1,
       intFromEnv(env.RATE_LIMIT_WINDOW_MS, RATE_LIMIT_DEFAULTS.windowMs)
     ),
-    trustProxy: intFromEnv(env.TRUST_PROXY, RATE_LIMIT_DEFAULTS.trustProxy),
+    trustProxy: hops ?? RATE_LIMIT_DEFAULTS.trustProxy,
+    trustProxyConfigured: hops !== undefined,
   };
 }
   const config = resolveRateLimitConfig(env);
   if (config.max <= 0) return {};

+  // Refuse to guess the topology: an unconfigured limiter behind an LB is not
+  // protection, it is a self-inflicted global throttle. Staying inert matches
+  // pre-#166 behaviour exactly, so this can never be the cause of an outage.
+  if (!config.trustProxyConfigured) {
+    warn(
+      '[rate-limit] TRUST_PROXY is not set — rate limiting is DISABLED. No ' +
+        'default is safe: keying on the socket address behind a load balancer ' +
+        'puts every client in one bucket and throttles the whole service, and ' +
+        'trusting X-Forwarded-For without a hop count lets any caller bypass ' +
+        'the limit. Set TRUST_PROXY=0 for a directly-exposed server, or to the ' +
+        'number of proxy hops in front of this API. NOTE: a GCP external ' +
+        'Application Load Balancer appends TWO entries (client IP, then ' +
+        'forwarding-rule IP), so a bare GCP LB is 2 — add 1 per extra ' +
+        'in-cluster proxy. Set RATE_LIMIT_MAX=0 to silence this.'
+    );
+    return {};
+  }
+
   const limiter = createRateLimiter(config);

Extend the existing TRUST_PROXY=0-but-proxied warning to report the chain length, so the operator can read the right hop count straight off the log instead of guessing (length only — no addresses, so no client IPs in logs):

   const warnIfProxied = (request: Request) => {
-    if (warnedAboutProxy || !request.headers.has('x-forwarded-for')) return;
+    const chain = request.headers.get('x-forwarded-for');
+    if (warnedAboutProxy || chain === null) return;
     warnedAboutProxy = true;
+    const hops = chain.split(',').filter((p) => p.trim()).length;
     warn(
       '[rate-limit] TRUST_PROXY=0 but requests carry X-Forwarded-For — every ' +
         'client behind the proxy shares one rate-limit bucket. Set TRUST_PROXY ' +
-        'to the number of proxy hops in front of this API (e.g. 1 behind a load ' +
-        'balancer) to bucket clients individually.'
+        `to the number of proxy hops in front of this API (observed chain ` +
+        `length: ${hops}) to bucket clients individually.`
     );
   };

And make the preflight exemption explicit rather than an unwritten dependency on plugin order — you are about to re-shuffle plugin ordering in the #185#191 reconcile, and a throttled preflight surfaces in the browser as an opaque CORS failure, not a 429:

     onRequest({ request, serverContext, url, endResponse, fetchAPI }) {
       if (url.pathname === HEALTHCHECK_PATH) return;
+      // Yoga's useCORS answers OPTIONS before user-land plugins today, so this
+      // is belt-and-braces — but a plugin-order change would turn a throttled
+      // preflight into an opaque browser CORS error rather than a clean 429.
+      if (request.method === 'OPTIONS') return;
       warnIfProxied(request);

tests/unit/rate-limit.test.ts

Update the one config assertion that now needs the new field, and add the three regression tests:

       assert.deepStrictEqual(
         resolveRateLimitConfig({
           RATE_LIMIT_MAX: '100',
           RATE_LIMIT_WINDOW_MS: '5000',
           TRUST_PROXY: '2',
         }),
-        { max: 100, windowMs: 5000, trustProxy: 2 }
+        { max: 100, windowMs: 5000, trustProxy: 2, trustProxyConfigured: true }
       );
    test('an unset TRUST_PROXY is not the same as an explicit 0', () => {
      assert.strictEqual(resolveRateLimitConfig({}).trustProxyConfigured, false);
      assert.strictEqual(
        resolveRateLimitConfig({ TRUST_PROXY: '0' }).trustProxyConfigured,
        true
      );
      // A typo must not read as a deliberate directly-exposed deployment.
      assert.strictEqual(
        resolveRateLimitConfig({ TRUST_PROXY: 'yes' }).trustProxyConfigured,
        false
      );
    });

    test('stays inert until TRUST_PROXY states the topology', async () => {
      // The failure this guards: behind an LB with TRUST_PROXY unset, every
      // client shares the socket bucket and 600/min becomes a global ceiling.
      // mina-explorer-api's backfill alone is 4 req/s (240/min).
      const warnings: string[] = [];
      const yoga = createYoga({
        schema,
        graphqlEndpoint: '/',
        plugins: [useRateLimit({ RATE_LIMIT_MAX: '1' }, (m) => warnings.push(m))],
      });
      const request = () =>
        yoga.fetch('http://localhost/', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ query: '{ __typename }' }),
        });
      for (let i = 0; i < 5; i++) {
        assert.strictEqual((await request()).status, 200);
      }
      assert.strictEqual(warnings.length, 1);
      assert.match(warnings[0], /TRUST_PROXY is not set  rate limiting is DISABLED/);
    });

    test('never counts CORS preflight requests', async () => {
      // A 429'd preflight is an opaque CORS error in the browser, not a 429.
      const origin = 'https://explorer.example.com';
      const yoga = createYoga({
        schema,
        graphqlEndpoint: '/',
        cors: { origin, methods: ['GET', 'POST'] },
        plugins: [useRateLimit({ RATE_LIMIT_MAX: '2', TRUST_PROXY: '1' })],
      });
      const preflight = () =>
        yoga.fetch('http://localhost/', {
          method: 'OPTIONS',
          headers: {
            origin,
            'x-forwarded-for': '8.8.8.8',
            'access-control-request-method': 'POST',
            'access-control-request-headers': 'content-type',
          },
        });
      for (let i = 0; i < 5; i++) {
        assert.strictEqual((await preflight()).status, 204);
      }
      // The budget must be untouched: 2 POSTs still allowed after 5 preflights.
      const post = () =>
        yoga.fetch('http://localhost/', {
          method: 'POST',
          headers: {
            'content-type': 'application/json',
            origin,
            'x-forwarded-for': '8.8.8.8',
          },
          body: JSON.stringify({ query: '{ __typename }' }),
        });
      assert.strictEqual((await post()).status, 200);
      assert.strictEqual((await post()).status, 200);
      assert.strictEqual((await post()).status, 429);
    });

docs/getting-started.md

-| `TRUST_PROXY` | `0` | Number of trusted proxy hops in front of the API. `0` ignores `X-Forwarded-For` and keys the rate limit on the socket address |
+| `TRUST_PROXY` | _(unset)_ | Number of trusted proxy hops in front of the API. **Required when `RATE_LIMIT_MAX > 0`** — the limiter stays disabled until it is set. `0` ignores `X-Forwarded-For` and keys on the socket address |
-- Behind a proxy or load balancer, set `TRUST_PROXY` to the number of hops in front of the API (`1` for a single ingress/LB). The client is then read as the Nth entry from the *right* of `X-Forwarded-For` — the part your own proxies appended — so anything the caller prepended is ignored. This is what keeps NAT'd and LB-fronted users in their own buckets rather than sharing one; leaving it at `0` behind an LB collapses every client onto the LB's address.
-- Set it to the actual hop count: too high and requests fall back to the socket address; too low and you key on an attacker-controlled entry.
+- `TRUST_PROXY` has **no default**. With `RATE_LIMIT_MAX > 0` and `TRUST_PROXY` unset the limiter logs an error and stays disabled, because neither reading is safe to assume: socket-keying behind a load balancer collapses every client into one bucket, and trusting `X-Forwarded-For` without a hop count lets any caller mint a fresh bucket per request.
+- Set `TRUST_PROXY=0` only for a directly-exposed server. Behind a proxy, set it to the number of hops your own infrastructure appends; the client is then read as the Nth entry from the *right* of `X-Forwarded-For`, so anything the caller prepended is ignored.
+- **Counting the hops:** a GCP external Application Load Balancer appends **two** entries — the client IP, then the forwarding-rule IP — so a bare GCP LB is `TRUST_PROXY=2`, plus one for each additional in-cluster proxy (e.g. an nginx ingress). Getting this wrong fails silently in both directions: too high falls back to the socket address, too low keys on your own proxy's IP and collapses every client into one bucket. The "TRUST_PROXY=0 but requests carry X-Forwarded-For" warning reports the observed chain length — use it to confirm.

.env.example.compose

-# Trusted proxy hops in front of the API; 0 ignores X-Forwarded-For and keys on
-# the socket address. Set to the real hop count (e.g. 1) when behind an LB.
+# Trusted proxy hops in front of the API. REQUIRED when RATE_LIMIT_MAX > 0 —
+# the limiter stays disabled until this is set, because no default is safe.
+# 0 = directly exposed (ignore X-Forwarded-For, key on the socket address).
+# Behind a GCP external ALB use 2 (it appends client IP + forwarding-rule IP),
+# plus 1 per additional in-cluster proxy hop.
 TRUST_PROXY=0

One thing this PR cannot do itself

Landing the patch makes the default safe, not the deployment protected. Set TRUST_PROXY explicitly in the deployment repo alongside the merge, otherwise #166 ships as a no-op. Confirm the value against a real request rather than assuming — the new warning prints the observed chain length on the first proxied request with TRUST_PROXY=0, which is the cheapest way to read it off production.


Non-blocking nits

  • Access-Control-Expose-Headers is not set in src/server/server.ts:23-26, so browser JS cannot read Retry-After / X-RateLimit-* off the 429. mina-explorer doesn't read them today, so this is cosmetic — but if you want them usable, add exposedHeaders: ['Retry-After', 'X-RateLimit-Limit', 'X-RateLimit-Remaining'] to the cors block.
  • The warning goes to console.warn, bypassing the yoga logger that LOG_LEVEL configures. Since the warning is the whole safety net for a misconfigured hop count, it is worth routing through the same logger so it lands in the log pipeline.
  • mina-explorer-api does not retry 429 (app/upstream/graphql.py:244-246 retries only >= 500) and ignores Retry-After. That is the right behaviour under a limiter — no retry storm — just noting the header is decorative for that consumer.

Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api.

dkijania added a commit that referenced this pull request Aug 24, 2026
X-Forwarded-For was trusted unconditionally and read left-to-right, so a
single source could rotate the header to mint a fresh bucket per request
and bypass the limit entirely — the abuse this plugin exists to stop —
while each forged value also added a Map entry that only cleared at
window end.

Dropping the header isn't the fix: honouring it is what keeps NAT'd and
LB-fronted clients in their own buckets rather than collapsing onto one
address. Instead TRUST_PROXY declares how many proxy hops sit in front of
the API, and the client is read as that many entries from the right — the
portion our own proxies appended — so prepended values are ignored. It
defaults to 0, which ignores forwarding headers and keys on the socket
address: the safe reading for a directly-exposed server. X-Real-IP is
gated the same way, and a chain shorter than the hop count falls back to
the socket rather than trusting a caller-supplied entry.

Also pins two contracts the limiter must not regress: the healthcheck is
never throttled, and the short-circuited 429 still carries CORS headers
(verified — yoga's CORS runs on onResponse), so cross-origin clients get
a readable 429 instead of an opaque CORS error.

Addresses review feedback on #185.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 24, 2026
…Y row

Scaling told operators to "add read replicas and point PG_CONN at them
before scaling the API further", which reads as added read capacity. It
isn't: postgres.js scopes hostIndex per Connection, so every pooled
connection starts at host[0] and only advances on failure. Extra hosts
buy redundancy, not throughput — real read scaling needs a balancer in
front of Postgres. The failover section now says so plainly rather than
leaving "connects to an available host" open to the throughput reading.

Adds a version scope note. Nearly everything the runbook says to observe
or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are
no-ops, and SIGTERM skips the drain. A runbook that misdirects mid-
incident is worse than no runbook, and the published image today is
0.0.6. Scoping by version rather than by in-flight PR numbers keeps the
note true after the merge train lands.

Splits the 429 incident row: after #185, mass 429s across unrelated
clients most likely means TRUST_PROXY is unset behind a gateway,
collapsing every client into one bucket — a different fix from a single
client exceeding the limit.

Addresses review feedback on #197.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 24, 2026
Three corrections, all of which would have misled operators:

CORS. The checklist told operators to set an allowlist "or leave unset —
not *", which would block every cross-origin browser client, the
mina-explorer included, with no server-side symptom. For a public
read-only API over already-public data, CORS_ORIGIN=* is the correct
setting rather than a lapse: CORS constrains browsers, not curl, so it
is not an access control. Adds a section making the choice explicit and
notes these controls arrive in 1.0.0 — on 0.0.x, CORS_ORIGIN defaults to
'*', so the protections table describes a version most operators are not
yet running.

TRUST_PROXY. The doc described X-Forwarded-For as read "first hop",
which is the behaviour removed in #185 as a rate-limit bypass. Documents
the hop-count model and the deny-by-default reading instead.

Read replicas. The README claimed the server "fans queries across"
multiple PG_CONN hosts. It does not: postgres.js scopes hostIndex per
Connection (src/connection.js:89), so every pooled connection starts at
host[0] and only advances on failure — failover, not fan-out. As written
it promised read scaling that adding replicas cannot deliver.

Addresses review feedback on #186.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SanabriaRusso
SanabriaRusso previously approved these changes Aug 24, 2026

@SanabriaRusso SanabriaRusso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All four round-1 unblock items verified fixed, plus the two "verified OK" items confirmed to have survived the rebase.

Verified fixed

  1. Hop count is correct and configurable. src/server/rate-limit.ts:175 keys on forwarded[forwarded.length - trustProxy] — the Nth entry from the right. GCP's external ALB emits X-Forwarded-For: <supplied>,<client-ip>,<lb-ip>, so TRUST_PROXY=2 selects the client IP and anything the caller prepends only shifts the index harmlessly. The docs and .env.example.compose:15-16 both say 2 for a bare GCP ALB plus 1 per extra in-cluster hop — the number is right, checked against the indexing expression rather than taken on faith.

  2. The global-ceiling failure mode is gone, by re-scoping rather than raising. TRUST_PROXY now has no default (rate-limit.ts:85) and useRateLimit returns an inert plugin when it is unset (:196-209) after logging a "rate limiting is DISABLED" warning. The shipped default cannot produce a 600 req/min global bucket because it produces no limiter at all. For the record, the configuration that would still fail — TRUST_PROXY=0 + RATE_LIMIT_MAX=600 behind the LB — works out to: explorer-api backfill 4 rps = 240/min = 40% of one shared bucket, and a mina-explorer page load is 4–6 requests, so ~6 concurrent browser sessions exhaust the remaining 360/min and the backfill starts 429ing too. That now requires an operator to explicitly write TRUST_PROXY=0 into a proxied topology, against an inline comment in the same file telling them to use 2, with a one-shot runtime warning reporting the observed chain length (:216-231). That clears the bar.

  3. Both probes are exempt. EXEMPT_PATHS (:12) covers /healthcheck and /readiness, checked at :238. /healthcheck is the liveness probe — it is the only health endpoint on main (src/server/server.ts:20, yoga's healthCheckEndpoint), so liveness is covered too.

  4. The bucket map is bounded. prune() (:124) on an unref'd setInterval at the window length (:233) — O(n) once per window, not per request, so it does not degrade under attack.

  5. 429 shape survived the rebase (:255-267): HTTP 429, body {errors:[{message:"Too many requests. Please retry later.", extensions:{code:"RATE_LIMITED"}}]}, with retry-after and x-ratelimit-*. It still cannot be mistaken for a schema error — the text matches none of mina-explorer-api's SCHEMA_ERROR_MARKERS (app/upstream/graphql.py:31-35: "Cannot query field", "Unknown argument", "Unknown type"), so it classifies as a generic upstream error rather than poisoning the capability cache. OPTIONS exemption survived at :242.

One thing that changed underneath this PR and is worth knowing

rate-limit.ts:242's OPTIONS check is now load-bearing, not defence in depth. graphql-yoga/cjs/server.js:171 is options?.cors !== false && useCORS(...), so once #184 lands with its secure default (cors: false) yoga's CORS plugin — and its OPTIONS stopEarly() short-circuit — is not registered at all. Confirmed empirically: with cors: false a preflight traverses the whole user-plugin chain. Round 1's "preflight is exempt because useCORS runs first" no longer holds; your local check is what saves it. Please adjust the comment above it so nobody later deletes it as redundant.

This matters for mina-explorer specifically: it sends Content-Type: application/json (src/services/api/daemon.ts:36), which is not a CORS-safelisted value, so every GraphQL POST from the browser is preceded by a preflight. Preflights are roughly half the request count from that consumer, not an edge case.

Non-blocking notes

  • x-real-ip fallback (:181) contradicts the comment above it (:172-174), which says the short-chain case falls through to the socket "rather than trust an attacker-chosen entry" — but x-real-ip is equally caller-controlled. Unreachable behind the ALB (it always appends XFF, so the early return fires), so this only matters for direct-to-pod traffic, which is already a trust-boundary breach. Consider dropping the branch or gating it separately.
  • No max-size cap on the bucket map. TTL eviction is right; within one window the map still grows with unique source IPs, bounded in practice by connection rate rather than by the limiter. A MAX_BUCKETS ceiling would close the residual cheaply.
  • Add '/metrics' to EXEMPT_PATHS once #191 lands. In-cluster scrapers send no XFF so they key on the socket address, and a 15–30 s scrape is ~4/min against 600 — no real risk, but a throttled scrape produces a metrics gap exactly when you most want one.
  • Trailing slash: #187 normalizes /readiness//readiness; EXEMPT_PATHS.has(url.pathname) does not. Cosmetic — kubelet uses the literal manifest path.
  • Retry-After / X-RateLimit-* are unreadable cross-origin. They are not CORS-safelisted and neither this PR nor #184 sets exposedHeaders, so a browser client sees the 429 status but not the backoff hint.
  • Test gap worth closing before merge. The suite pins the rotation attack at TRUST_PROXY=1 and the short-chain fallback at 2, but there is no positive test at TRUST_PROXY=2 with a two-entry chain — the exact configuration the docs tell every GCP operator to use. An off-by-one there would silently reinstate the original blocker with no failing test. Suggested:
test('with a GCP ALB (2 hops) the client is the second entry from the right', async () => {
  // Chain as GCP writes it: <caller-supplied>, <client-ip>, <lb-ip>
  const request = makeServer('2');
  assert.strictEqual((await request('a.a.a.a, 1.2.3.4, 35.1.1.1')).status, 200);
  assert.strictEqual((await request('b.b.b.b, 1.2.3.4, 35.1.1.1')).status, 200);
  // Same real client, rotated prefix -> still the same bucket.
  assert.strictEqual((await request('c.c.c.c, 1.2.3.4, 35.1.1.1')).status, 429);
  // A different real client is unaffected.
  assert.strictEqual((await request('c.c.c.c, 9.9.9.9, 35.1.1.1')).status, 200);
});

Downstream: no impact at the shipped default, because the limiter is inert until TRUST_PROXY is set. Once enabled with a correct hop count, explorer-api's 4 rps backfill gets its own bucket (240/min vs 600) and browser traffic buckets per end-user IP.

One thing to be aware of when you do enable it: mina-explorer-api does not retry 4xx (app/upstream/graphql.py, _execute retries only on transport error / timeout / HTTP ≥ 500), and a 429 without a GraphQL errors[] array raises the base UpstreamError, which query() maps to outcome="unavailable"breaker.record_failure(). Your 429 does carry an errors[] array, so it takes the classify_graphql_errors path instead — still a generic UpstreamError, still a breaker failure. Either way a sustained 429 burst drives that consumer's per-endpoint circuit breaker toward OPEN, after which it short-circuits every call with zero I/O for the cooldown. That is correct limiter behaviour, not a bug here — but it means the first production value for RATE_LIMIT_MAX should be set generously and watched, because the downstream failure is a step function rather than a gradual degradation.

dkijania added a commit that referenced this pull request Aug 24, 2026
…Y row

Scaling told operators to "add read replicas and point PG_CONN at them
before scaling the API further", which reads as added read capacity. It
isn't: postgres.js scopes hostIndex per Connection, so every pooled
connection starts at host[0] and only advances on failure. Extra hosts
buy redundancy, not throughput — real read scaling needs a balancer in
front of Postgres. The failover section now says so plainly rather than
leaving "connects to an available host" open to the throughput reading.

Adds a version scope note. Nearly everything the runbook says to observe
or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are
no-ops, and SIGTERM skips the drain. A runbook that misdirects mid-
incident is worse than no runbook, and the published image today is
0.0.6. Scoping by version rather than by in-flight PR numbers keeps the
note true after the merge train lands.

Splits the 429 incident row: after #185, mass 429s across unrelated
clients most likely means TRUST_PROXY is unset behind a gateway,
collapsing every client into one bucket — a different fix from a single
client exceeding the limit.

Addresses review feedback on #197.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 24, 2026
…Y row

Scaling told operators to "add read replicas and point PG_CONN at them
before scaling the API further", which reads as added read capacity. It
isn't: postgres.js scopes hostIndex per Connection, so every pooled
connection starts at host[0] and only advances on failure. Extra hosts
buy redundancy, not throughput — real read scaling needs a balancer in
front of Postgres. The failover section now says so plainly rather than
leaving "connects to an available host" open to the throughput reading.

Adds a version scope note. Nearly everything the runbook says to observe
or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are
no-ops, and SIGTERM skips the drain. A runbook that misdirects mid-
incident is worse than no runbook, and the published image today is
0.0.6. Scoping by version rather than by in-flight PR numbers keeps the
note true after the merge train lands.

Splits the 429 incident row: after #185, mass 429s across unrelated
clients most likely means TRUST_PROXY is unset behind a gateway,
collapsing every client into one bucket — a different fix from a single
client exceeding the limit.

Addresses review feedback on #197.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 24, 2026
…Y row

Scaling told operators to "add read replicas and point PG_CONN at them
before scaling the API further", which reads as added read capacity. It
isn't: postgres.js scopes hostIndex per Connection, so every pooled
connection starts at host[0] and only advances on failure. Extra hosts
buy redundancy, not throughput — real read scaling needs a balancer in
front of Postgres. The failover section now says so plainly rather than
leaving "connects to an available host" open to the throughput reading.

Adds a version scope note. Nearly everything the runbook says to observe
or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are
no-ops, and SIGTERM skips the drain. A runbook that misdirects mid-
incident is worse than no runbook, and the published image today is
0.0.6. Scoping by version rather than by in-flight PR numbers keeps the
note true after the merge train lands.

Splits the 429 incident row: after #185, mass 429s across unrelated
clients most likely means TRUST_PROXY is unset behind a gateway,
collapsing every client into one bucket — a different fix from a single
client exceeding the limit.

Addresses review feedback on #197.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 24, 2026
X-Forwarded-For was trusted unconditionally and read left-to-right, so a
single source could rotate the header to mint a fresh bucket per request
and bypass the limit entirely — the abuse this plugin exists to stop —
while each forged value also added a Map entry that only cleared at
window end.

Dropping the header isn't the fix: honouring it is what keeps NAT'd and
LB-fronted clients in their own buckets rather than collapsing onto one
address. Instead TRUST_PROXY declares how many proxy hops sit in front of
the API, and the client is read as that many entries from the right — the
portion our own proxies appended — so prepended values are ignored. It
defaults to 0, which ignores forwarding headers and keys on the socket
address: the safe reading for a directly-exposed server. X-Real-IP is
gated the same way, and a chain shorter than the hop count falls back to
the socket rather than trusting a caller-supplied entry.

Also pins two contracts the limiter must not regress: the healthcheck is
never throttled, and the short-circuited 429 still carries CORS headers
(verified — yoga's CORS runs on onResponse), so cross-origin clients get
a readable 429 instead of an opaque CORS error.

Addresses review feedback on #185.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 24, 2026
Three corrections, all of which would have misled operators:

CORS. The checklist told operators to set an allowlist "or leave unset —
not *", which would block every cross-origin browser client, the
mina-explorer included, with no server-side symptom. For a public
read-only API over already-public data, CORS_ORIGIN=* is the correct
setting rather than a lapse: CORS constrains browsers, not curl, so it
is not an access control. Adds a section making the choice explicit and
notes these controls arrive in 1.0.0 — on 0.0.x, CORS_ORIGIN defaults to
'*', so the protections table describes a version most operators are not
yet running.

TRUST_PROXY. The doc described X-Forwarded-For as read "first hop",
which is the behaviour removed in #185 as a rate-limit bypass. Documents
the hop-count model and the deny-by-default reading instead.

Read replicas. The README claimed the server "fans queries across"
multiple PG_CONN hosts. It does not: postgres.js scopes hostIndex per
Connection (src/connection.js:89), so every pooled connection starts at
host[0] and only advances on failure — failover, not fan-out. As written
it promised read scaling that adding replicas cannot deliver.

Addresses review feedback on #186.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 26, 2026
X-Forwarded-For was trusted unconditionally and read left-to-right, so a
single source could rotate the header to mint a fresh bucket per request
and bypass the limit entirely — the abuse this plugin exists to stop —
while each forged value also added a Map entry that only cleared at
window end.

Dropping the header isn't the fix: honouring it is what keeps NAT'd and
LB-fronted clients in their own buckets rather than collapsing onto one
address. Instead TRUST_PROXY declares how many proxy hops sit in front of
the API, and the client is read as that many entries from the right — the
portion our own proxies appended — so prepended values are ignored. It
defaults to 0, which ignores forwarding headers and keys on the socket
address: the safe reading for a directly-exposed server. X-Real-IP is
gated the same way, and a chain shorter than the hop count falls back to
the socket rather than trusting a caller-supplied entry.

Also pins two contracts the limiter must not regress: the healthcheck is
never throttled, and the short-circuited 429 still carries CORS headers
(verified — yoga's CORS runs on onResponse), so cross-origin clients get
a readable 429 instead of an opaque CORS error.

Addresses review feedback on #185.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 26, 2026
Three corrections, all of which would have misled operators:

CORS. The checklist told operators to set an allowlist "or leave unset —
not *", which would block every cross-origin browser client, the
mina-explorer included, with no server-side symptom. For a public
read-only API over already-public data, CORS_ORIGIN=* is the correct
setting rather than a lapse: CORS constrains browsers, not curl, so it
is not an access control. Adds a section making the choice explicit and
notes these controls arrive in 1.0.0 — on 0.0.x, CORS_ORIGIN defaults to
'*', so the protections table describes a version most operators are not
yet running.

TRUST_PROXY. The doc described X-Forwarded-For as read "first hop",
which is the behaviour removed in #185 as a rate-limit bypass. Documents
the hop-count model and the deny-by-default reading instead.

Read replicas. The README claimed the server "fans queries across"
multiple PG_CONN hosts. It does not: postgres.js scopes hostIndex per
Connection (src/connection.js:89), so every pooled connection starts at
host[0] and only advances on failure — failover, not fan-out. As written
it promised read scaling that adding replicas cannot deliver.

Addresses review feedback on #186.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@SanabriaRusso SanabriaRusso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving after the rebase (the previous approval was dismissed by the force-push).

Re-verified: the PR content at ec86cbb differs from the commit I approved (cf9eb0f) by one blank line in src/server/plugins.ts — absorbed while rebasing over #191's metrics plugin. No semantic change.

Carrying forward the round-2 verification unchanged:

  • Hop count is the Nth entry from the right; docs say 2 for a GCP ALB, and that matches the code.
  • The global-ceiling failure mode is gone by re-scoping: TRUST_PROXY has no default and the limiter is inert until it is set (rate-limit.ts:198 returns an empty plugin with a loud warning). The shipped default cannot produce a 600/min global bucket because it produces no limiter at all.
  • Both probes exempt; the bucket map is bounded by a TTL sweep that is O(n) once per window, not per request.

Non-blocking, but two are worth taking in this PR:

  1. EXEMPT_PATHS is ['/healthcheck', '/readiness']/metrics is still missing, and it now exists on main (#191 merged). A Prometheus scraper hitting it every 15s from one source IP burns rate-limit budget for no reason. One-word fix.
  2. No positive test at TRUST_PROXY=2 with a two-entry chain — the exact config every GCP operator is told to use. An off-by-one there would silently reinstate the round-1 blocker. Test supplied in the earlier comment.
  3. The x-real-ip fallback contradicts its own comment.

Rollout note: mina-explorer-api does not retry 4xx, and a 429 without a GraphQL errors[] array raises the base UpstreamErroroutcome="unavailable"breaker.record_failure(). A sustained 429 burst opens that consumer's per-endpoint circuit breaker, after which every call short-circuits with zero I/O for the cooldown — a step function, not gradual throttling. Set the first production RATE_LIMIT_MAX generously and watch it.

dkijania and others added 5 commits August 26, 2026 23:52
A public GraphQL endpoint with no throttle lets a single client monopolise the
server and the backing Postgres. Add a global, per-client-IP rate limiter that
runs on every request before GraphQL parsing, rejecting over-limit traffic with
HTTP 429 as cheaply as possible.

- RATE_LIMIT_MAX        (requests per client per window, default 600; 0 disables)
- RATE_LIMIT_WINDOW_MS  (window length in ms, default 60000)

The client IP is taken from X-Forwarded-For (first hop), then X-Real-IP, then
the socket address, falling back to a shared `unknown` bucket so unproxied
traffic is still bounded. Health checks are never throttled. The fixed-window
counter is in-memory and per-instance; a shared store for exact cross-replica
limits is left as deployment hardening and noted in the docs.

Implemented as a custom plugin rather than @envelop/rate-limiter, whose
directive model bakes limits into the schema and is per-field rather than a
global per-IP bucket. Malformed env values fall back to safe defaults. Unit
tests cover config parsing, the windowing/reset/prune logic with an injected
clock, and prove end-to-end through Yoga that the (max+1)th request from an IP
gets 429 while other clients are unaffected.

Closes #166.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6
X-Forwarded-For was trusted unconditionally and read left-to-right, so a
single source could rotate the header to mint a fresh bucket per request
and bypass the limit entirely — the abuse this plugin exists to stop —
while each forged value also added a Map entry that only cleared at
window end.

Dropping the header isn't the fix: honouring it is what keeps NAT'd and
LB-fronted clients in their own buckets rather than collapsing onto one
address. Instead TRUST_PROXY declares how many proxy hops sit in front of
the API, and the client is read as that many entries from the right — the
portion our own proxies appended — so prepended values are ignored. It
defaults to 0, which ignores forwarding headers and keys on the socket
address: the safe reading for a directly-exposed server. X-Real-IP is
gated the same way, and a chain shorter than the hop count falls back to
the socket rather than trusting a caller-supplied entry.

Also pins two contracts the limiter must not regress: the healthcheck is
never throttled, and the short-circuited 429 still carries CORS headers
(verified — yoga's CORS runs on onResponse), so cross-origin clients get
a readable 429 instead of an opaque CORS error.

Addresses review feedback on #185.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The secure default is wrong for the expected production topology, where
it collapses every client onto the load balancer's address. That symptom
surfaces only as unexplained throttling, so warn once on the first
forwarded request — once rather than per request, since this sits on the
hot path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dkijania
dkijania merged commit 3fd3379 into main Aug 26, 2026
9 checks passed
@dkijania
dkijania deleted the feat/rate-limit branch August 26, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P0 Blocker for public availability production-readiness Work toward making the API production-ready / publicly available

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P0: Add rate limiting (edge or in-process)

2 participants