From a37dccffa8032648ad53ae22958cd5bf4f48af59 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 16:52:31 +0000 Subject: [PATCH] The rate limit answers 402 with the offer, not 429 with a description of it The refusal already named the pass, its price and exactly how to buy it. In prose. An x402 client cannot act on prose, so a program that hit the wall had the same two options it arrived with: slow down, or spread itself over a proxy pool. It had to make a second trip to /crawl to find out what to sign. Now the refusal carries the offer itself, read off the gateway rather than rebuilt here -- payTo, the price, the currency and the network live in one place, and a second copy is how a site ends up quoting a number its own /crawl disagrees with. That is what @profullstack/x402-gateway 0.6.0 adds: `gateway.offer()`, which was already computed inside the closure and is now just not private. The paid rungs still get 429. A caller at the sponsor ceiling has bought everything there is, and a 402 would be asking it to pay twice for nothing. Tripping the anonymous rung takes 120 requests in a minute, against a steady reader's thirty, so the caller being answered here is a machine. The tiered throttle in lib/crawlThrottle.js is untouched: it keys a declared crawler by its own bot token, which collapses a seventy-address fleet into one caller, and nothing in @profullstack/throttle does that. This is the sale it was missing, not a replacement for it. Read off the source, the way the matcher test already is: importing proxy.js under node:test fails on a bare `next/server` specifier, which is why none of the tests here drive the proxy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YDGCxTmEPs3ecwjjLJDQXh --- apps/web/package.json | 2 +- apps/web/src/proxy.js | 38 ++++++++++++++++++++----- apps/web/test/proxy.test.js | 56 +++++++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 10 +++---- pnpm-workspace.yaml | 2 +- 5 files changed, 94 insertions(+), 14 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index a807abb..ce39a1a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,7 @@ "dependencies": { "@profullstack/player": "^0.3.1", "@profullstack/rssamplifier": "workspace:*", - "@profullstack/x402-gateway": "0.4.0", + "@profullstack/x402-gateway": "^0.6.0", "@rssamplifier/auth": "workspace:*", "@rssamplifier/db": "workspace:*", "@rssamplifier/feed": "workspace:*", diff --git a/apps/web/src/proxy.js b/apps/web/src/proxy.js index b7e362b..2b5ec85 100644 --- a/apps/web/src/proxy.js +++ b/apps/web/src/proxy.js @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { SIGNED_IN_HINT_COOKIE, hintToRestore } from './lib/session-hint.js'; -import { gate, hasValidPass } from './lib/crawl-gateway.js'; +import { gate, gateway, hasValidPass } from './lib/crawl-gateway.js'; import { attempt, callerIdentity } from './lib/crawlThrottle.js'; import { countRequest } from './lib/trafficCounter.js'; import { TIERS, tierFor } from './lib/tiers.js'; @@ -106,11 +106,26 @@ export async function proxy(request) { /** * The refusal. * - * Says which rung the caller is on and what the next one costs, because a 429 - * that only says "slow down" leaves a caller with nothing to do but retry — - * and the whole point of a ladder is that there is somewhere to go. The upgrade - * path is spelled out rather than linked alone: an agent reading this is - * exactly the reader who can act on it without a human. + * Says which rung the caller is on and what the next one costs, because a + * refusal that only says "slow down" leaves a caller with nothing to do but + * retry — and the whole point of a ladder is that there is somewhere to go. The + * upgrade path is spelled out rather than linked alone: an agent reading this + * is exactly the reader who can act on it without a human. + * + * ## Why this is a 402 and not a 429 + * + * It said the price in prose and then made the reader go and fetch /crawl to + * find out what to sign. An x402 client cannot act on prose. Carrying the + * offer here — the same one /crawl serves, read straight off the gateway so + * the two can never quote different numbers — means a program that hits the + * wall can pay and carry on inside the same exchange, which is the difference + * between a price and a sign about a price. Tripping the anonymous rung takes + * 120 requests in a minute against a steady reader's thirty, so the caller + * being answered here is a machine. + * + * The paid rungs still get 429. A caller at the sponsor ceiling has already + * bought everything there is, and answering it 402 would be asking it to pay + * twice for nothing. * * @param {{ retryAfter: number }} verdict * @param {{ name: string, burst: number, hourly: number }} tier @@ -147,8 +162,17 @@ function tooMany(verdict, tier) { how: 'Fetch https://rssamplifier.com/crawl with Accept: application/json for an x402 offer, pay it, then send the pass in the x-crawl-pass header. No account and no human needed.', }; + /* + * The offer itself, for the rungs that can still buy one. Read off the + * gateway rather than rebuilt here: payTo, the price, the currency and the + * network live in one place, and a second copy is how a site ends up quoting + * a number its own /crawl disagrees with. + */ + const offer = buyable ? gateway.offer() : null; + return NextResponse.json( { + ...(offer ?? {}), error: 'rate limit exceeded', // Said plainly, because the alternative is that they guess and retry. The // directory is still open to them; this is a speed limit, not a door. @@ -160,7 +184,7 @@ function tooMany(verdict, tier) { retryAfter: verdict.retryAfter, }, { - status: 429, + status: offer ? 402 : 429, headers: { 'retry-after': String(verdict.retryAfter), 'cache-control': 'no-store', diff --git a/apps/web/test/proxy.test.js b/apps/web/test/proxy.test.js index 8f1348a..0c5c56b 100644 --- a/apps/web/test/proxy.test.js +++ b/apps/web/test/proxy.test.js @@ -145,3 +145,59 @@ test('the masthead repair still skips what has no masthead to fix', () => { assert.ok(!wants.test('/robots.txt')); assert.ok(!wants.test('/topics/physics.rss'), 'a feed is subscribed to, not read in a browser'); }); + +/** + * The refusal is a 402 carrying the offer, not a 429 describing one. + * + * It used to say the price in prose and send the reader off to /crawl to find + * out what to sign. An x402 client cannot act on prose, so a program that hit + * the wall had the same two options it started with: slow down, or spread + * itself over a proxy pool. Carrying the offer means it can pay and carry on + * inside the same exchange. + * + * Read off the source for the same reason the matcher is: importing proxy.js + * under node:test fails on a bare `next/server` specifier, which is why none + * of the tests above drive the proxy either. + */ +test('the refusal carries an offer a program can pay', () => { + const source = proxySource(); + + assert.match( + source, + /const offer = buyable \? gateway\.offer\(\) : null;/, + 'the offer is read off the gateway, so /crawl and this can never quote different numbers', + ); + assert.match( + source, + /status: offer \? 402 : 429/, + 'a rung that can still buy something is answered 402; one that cannot stays 429', + ); + assert.ok( + source.includes('...(offer ?? {}),'), + 'and the accepts array is spread into the body', + ); + assert.ok( + !/\bstatus: 429\b/.test(source), + 'no refusal is left answering a flat 429 with a price only a person can read', + ); +}); + +test('the offer the refusal carries is payable', async () => { + // The half that cannot be read off the source: that gateway.offer() is a + // real x402 offer rather than an empty accepts list. Without a CoinPay key + // and a payTo it is empty by design, and the refusal falls back to 429. + const { gateway } = await import('../src/lib/crawl-gateway.js'); + const offer = gateway.offer(); + + assert.equal(offer.x402Version, 2, 'an x402 v2 offer'); + assert.ok(Array.isArray(offer.accepts), 'with an accepts array'); + + if (gateway.enabled) { + assert.ok(offer.accepts.length > 0, 'naming at least one network to pay on'); + for (const entry of offer.accepts) { + assert.equal(entry.scheme, 'exact'); + assert.ok(entry.payTo, 'and where the money goes'); + assert.ok(BigInt(entry.amount) > 0n, 'and how much'); + } + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36a6495..811c330 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,8 +56,8 @@ importers: specifier: workspace:* version: link:../cli '@profullstack/x402-gateway': - specifier: 0.4.0 - version: 0.4.0 + specifier: ^0.6.0 + version: 0.6.0 '@rssamplifier/auth': specifier: workspace:* version: link:../../packages/auth @@ -626,8 +626,8 @@ packages: engines: {node: '>=20.19'} hasBin: true - '@profullstack/x402-gateway@0.4.0': - resolution: {integrity: sha512-Rk3dtpwgi5rOIcHvsRqv7XvbT8rEHyhN/d3zdtsPe8zQfhhYWEjuFuvd/2bIWoWZ6fILQRQOCbejOe2VCRlsNg==} + '@profullstack/x402-gateway@0.6.0': + resolution: {integrity: sha512-n9/7NixIM9wVOMGWDc+3RtFsGABG+HAYfkZ11JxoEIlF/FDRGEXbvHFlNL/M97RNy01964FhAbQp34OMJoxqZg==} engines: {node: '>=20.11'} '@simplewebauthn/browser@13.3.0': @@ -1384,7 +1384,7 @@ snapshots: '@noble/curves': 2.4.0 '@noble/hashes': 2.4.0 - '@profullstack/x402-gateway@0.4.0': {} + '@profullstack/x402-gateway@0.6.0': {} '@simplewebauthn/browser@13.3.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9591678..a12d4f9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,7 +13,7 @@ allowBuilds: msgpackr-extract: false minimumReleaseAgeExclude: - '@profullstack/player@0.2.0 || 0.3.1' - - '@profullstack/x402-gateway@0.1.0 || 0.2.1 || 0.3.0 || 0.4.0' + - '@profullstack/x402-gateway@0.1.0 || 0.2.1 || 0.3.0 || 0.4.0 || 0.6.0' - '@profullstack/x402-client@0.2.0' - '@profullstack/leaderboard@0.3.0 || 0.3.1' - '@profullstack/partners@0.2.0'