Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
41 changes: 35 additions & 6 deletions packages/@eep-dev/compliance-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ const RECOMMENDATIONS: Record<string, string> = {
'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.',
Expand Down Expand Up @@ -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');
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/@eep-dev/gates/src/http-402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

43 changes: 43 additions & 0 deletions packages/@eep-dev/gates/src/problem-details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down