From 51e2c2b37573a0c78e6e10a2e185f39a5153ce3a Mon Sep 17 00:00:00 2001 From: Ugur Cekmez Date: Wed, 26 Aug 2026 22:30:24 +0300 Subject: [PATCH] feat(spec,gates): require the standards-track RateLimit header fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §13 mandated `X-RateLimit-Limit` / `-Remaining` / `-Reset` — the de-facto convention inherited from early drafts. `X-`-prefixed header names have been discouraged since RFC 6648 (2012), and mandating them on their own would be a predictable finding on the IETF path this project intends to take at v0.2. The standards-track form is `RateLimit` and `RateLimit-Policy` (draft-ietf-httpapi-ratelimit-headers, active in the httpapi WG). Both forms are kept. Removing `X-RateLimit-*` would break deployed clients for no functional gain, so §13 now requires the standards-track fields and keeps the de-facto ones as a compatibility SHOULD, with a rule that when both are present they MUST describe the same quota — an implementation that lets them drift is worse than one that sends only one. Changes: - §13 rewritten: `RateLimit` reports current state (`r` remaining, `t` seconds to reset), `RateLimit-Policy` describes the quota (`q`, `w`), `Retry-After` MUST accompany a 429. - `build429Response` emits `RateLimit` always, and `RateLimit-Policy` plus the `X-RateLimit-*` trio when a quota is known. On a 429 the remaining quota is zero by definition. - compliance-cli probes the standards-track fields as failures and the `X-` form as an optional skip, rather than probing only for `X-`. Refs: EEP audit 2026-08 finding B8 Signed-off-by: Ugur Cekmez --- docs/current/SPECIFICATION.md | 31 ++++++++++++- packages/@eep-dev/compliance-cli/src/index.ts | 41 +++++++++++++++--- packages/@eep-dev/gates/src/http-402.ts | 13 ++++++ .../gates/src/problem-details.test.ts | 43 +++++++++++++++++++ 4 files changed, 120 insertions(+), 8 deletions(-) diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index 9548bd9..b402c99 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -1308,15 +1308,42 @@ This allows agents to search for specific capabilities without downloading the e ## 13. Rate limiting -Publishers MUST enforce rate limits and return standard headers: +Publishers MUST enforce rate limits and advertise them in response headers. + +The standards-track form is the `RateLimit` and `RateLimit-Policy` fields from +the IETF `httpapi` working group. Publishers MUST send these: + +```http +RateLimit: "sub"; r=87; t=120 +RateLimit-Policy: "sub"; q=100; w=3600 +Retry-After: 120 +``` + +- `RateLimit` reports the **current** state of a named quota policy: `r` is the + remaining quota, `t` is the seconds until the quota partition resets. +- `RateLimit-Policy` describes the policy itself: `q` is the quota, `w` is the + window in seconds. A publisher MAY advertise several policies. +- `Retry-After` MUST accompany a `429`. + +Publishers SHOULD **also** send the widely deployed de-facto headers for +compatibility with existing clients: ```http X-RateLimit-Limit: 100 X-RateLimit-Remaining: 87 X-RateLimit-Reset: 1708168200 -Retry-After: 120 ``` +When both forms are present they MUST describe the same quota; a client that +reads either arrives at the same conclusion. + +> **Why both.** `X-`-prefixed header names have been discouraged since +> **RFC 6648 (2012)**, and mandating them on their own would be a predictable +> finding on the IETF path this project intends to take. But `X-RateLimit-*` +> is what deployed clients read today, so removing it would break them for no +> functional gain. EEP requires the standards-track form and keeps the +> de-facto form as a compatibility `SHOULD`. + Recommended default limits per subscriber: | Action | Limit | diff --git a/packages/@eep-dev/compliance-cli/src/index.ts b/packages/@eep-dev/compliance-cli/src/index.ts index b8615bf..20a6a98 100644 --- a/packages/@eep-dev/compliance-cli/src/index.ts +++ b/packages/@eep-dev/compliance-cli/src/index.ts @@ -151,7 +151,9 @@ const RECOMMENDATIONS: Record = { 'SSE stream endpoint': 'Expose authenticated SSE endpoint with Content-Type: text/event-stream.', 'SSE heartbeat (\u00a74.4)': 'Emit an SSE comment heartbeat (a line starting with ":") at least every 15 seconds so subscribers can detect stale connections.', 'SSE Last-Event-ID replay (\u00a74.3)': 'Honour the Last-Event-ID header (or last_event_id query param) by replaying events strictly after that id, with at least a 24h retention window.', - 'Rate limit headers present': 'Return X-RateLimit-* headers for protected endpoints.', + 'RateLimit header present (\u00a713)': 'Return the standards-track RateLimit field (e.g. `RateLimit: "sub"; r=87; t=120`) on protected endpoints.', + 'RateLimit-Policy header present (\u00a713)': 'Advertise the quota policy (e.g. `RateLimit-Policy: "sub"; q=100; w=3600`).', + 'X-RateLimit-* compatibility headers (\u00a713)': 'Optionally also send X-RateLimit-* for clients that read the de-facto form; when both are sent they MUST describe the same quota.', '/.well-known/eep.json manifest reachable': 'Serve eep manifest with stable URL and valid JSON contract.', 'manifest.did field present': 'Include did in manifest and keep it resolvable.', 'manifest.eep_version field present': 'Publish supported eep_version in manifest.', @@ -680,19 +682,46 @@ async function runTests() { logSkip('SSE Last-Event-ID replay (§4.3)', 'requires --api-key and --entity'); } - // Rate limit headers + // Rate limit headers (§13). + // + // The standards-track form (`RateLimit` / `RateLimit-Policy`) is + // required; `X-RateLimit-*` is a compatibility SHOULD. Probing only + // for the `X-` form would have kept EEP pinned to header names + // discouraged since RFC 6648. if (API_KEY) { try { const res = await fetch(`${TARGET}/eep/subscriptions`, { headers: { Authorization: `Bearer ${API_KEY}` }, }); - if (res.headers.has('x-ratelimit-limit')) pass('Rate limit headers present', 'X-RateLimit-* headers found'); - else fail('Rate limit headers present', 'X-RateLimit-Limit header missing'); + + const rateLimit = res.headers.get('ratelimit'); + const policy = res.headers.get('ratelimit-policy'); + if (rateLimit) { + logPass('RateLimit header present (§13)', rateLimit); + } else { + logFail('RateLimit header present (§13)', 'no RateLimit header; §13 requires the standards-track form'); + } + if (policy) { + logPass('RateLimit-Policy header present (§13)', policy); + } else { + logFail('RateLimit-Policy header present (§13)', 'no RateLimit-Policy header'); + } + + if (res.headers.has('x-ratelimit-limit')) { + logPass('X-RateLimit-* compatibility headers (§13)', 'present'); + } else { + logSkip( + 'X-RateLimit-* compatibility headers (§13)', + 'SHOULD, not MUST — only needed for clients that read the de-facto form', + ); + } } catch (e) { - fail('Rate limit headers present', String(e)); + logFail('RateLimit header present (§13)', String(e)); } } else { - skip('Rate limit headers', 'requires --api-key'); + logSkip('RateLimit header present (§13)', 'requires --api-key'); + logSkip('RateLimit-Policy header present (§13)', 'requires --api-key'); + logSkip('X-RateLimit-* compatibility headers (§13)', 'requires --api-key'); } } diff --git a/packages/@eep-dev/gates/src/http-402.ts b/packages/@eep-dev/gates/src/http-402.ts index aa6d02c..f15d58a 100644 --- a/packages/@eep-dev/gates/src/http-402.ts +++ b/packages/@eep-dev/gates/src/http-402.ts @@ -148,6 +148,19 @@ export async function build429Response( 'X-EEP-Rate-Reset': windowResetAt, }; + // SPECIFICATION.md §13: the standards-track RateLimit fields are required. + // On a 429 the remaining quota is zero by definition, and `t` is the + // seconds until the partition resets. + headers['RateLimit'] = `"eep"; r=0; t=${retryAfterSeconds}`; + if (limitPerWindow !== undefined) { + headers['RateLimit-Policy'] = `"eep"; q=${limitPerWindow}; w=${retryAfterSeconds}`; + // De-facto form, kept for clients that read it. Both describe the + // same quota, as §13 requires. + headers['X-RateLimit-Limit'] = String(limitPerWindow); + headers['X-RateLimit-Remaining'] = '0'; + headers['X-RateLimit-Reset'] = String(Math.floor(Date.parse(windowResetAt) / 1000)); + } + return { body, headers }; } diff --git a/packages/@eep-dev/gates/src/problem-details.test.ts b/packages/@eep-dev/gates/src/problem-details.test.ts index a81042a..0bcaf49 100644 --- a/packages/@eep-dev/gates/src/problem-details.test.ts +++ b/packages/@eep-dev/gates/src/problem-details.test.ts @@ -87,6 +87,49 @@ describe('RFC 9457 problem details (§3.3.1)', () => { }); }); + // SPECIFICATION.md §13 — the standards-track RateLimit fields are + // required; X-RateLimit-* is a compatibility SHOULD. Both must describe + // the same quota. + describe('429 rate-limit headers (§13)', () => { + const sign = async (challenge: string) => `sig(${challenge.slice(0, 8)})`; + + it('emits the standards-track RateLimit field', async () => { + const { headers } = await build429Response('did:key:agent', 60, sign); + // On a 429 the remaining quota is zero by definition. + expect(headers['RateLimit']).toBe('"eep"; r=0; t=60'); + }); + + it('emits RateLimit-Policy when a quota is known', async () => { + const { headers } = await build429Response('did:key:agent', 60, sign, { + limitPerWindow: 100, + }); + expect(headers['RateLimit-Policy']).toBe('"eep"; q=100; w=60'); + }); + + it('keeps the de-facto headers consistent with the standards-track ones', async () => { + const { headers } = await build429Response('did:key:agent', 60, sign, { + limitPerWindow: 100, + }); + expect(headers['X-RateLimit-Limit']).toBe('100'); + expect(headers['X-RateLimit-Remaining']).toBe('0'); + expect(headers['RateLimit-Policy']).toContain('q=100'); + expect(headers['RateLimit']).toContain('r=0'); + }); + + it('omits the quota headers when no quota was supplied', async () => { + const { headers } = await build429Response('did:key:agent', 60, sign); + expect(headers['RateLimit-Policy']).toBeUndefined(); + expect(headers['X-RateLimit-Limit']).toBeUndefined(); + // The current-state field is still reported. + expect(headers['RateLimit']).toBeDefined(); + }); + + it('always accompanies a 429 with Retry-After', async () => { + const { headers } = await build429Response('did:key:agent', 90, sign); + expect(headers['Retry-After']).toBe('90'); + }); + }); + it('uses distinct problem type URIs per condition', () => { expect(PROBLEM_TYPE_PAYMENT_REQUIRED).not.toBe(PROBLEM_TYPE_RATE_LIMITED); for (const uri of [PROBLEM_TYPE_PAYMENT_REQUIRED, PROBLEM_TYPE_RATE_LIMITED]) {