From 0d90a74bd29ac77d3f5a48fbdf1db93a41e48536 Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Thu, 15 Oct 2026 16:16:06 +0200 Subject: [PATCH] session: retry every request refused for a stale DPoP nonce Requests sent together over OAuth are all refused once the server rotates its nonce. The first answer taught the new nonce, and the others were not retried, because the nonce they were answered with was no longer new to the session. A request is now retried when the nonce known is not the one it was sent with. Finding a feed by name sends three requests at once, and took their failure for having no saved or own feeds: 'atcli feed show news-2-0' searched instead and found nothing. Failures to look are now said. --- src/feedref.ts | 11 +++++++---- src/session.ts | 12 +++++++++--- test/e2e.test.ts | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/feedref.ts b/src/feedref.ts index 3483f4b..e6f5c4e 100644 --- a/src/feedref.ts +++ b/src/feedref.ts @@ -140,14 +140,14 @@ async function savedCandidates(client: Client): Promise { async function ownCandidates(client: Client, did: string): Promise { const [feeds, lists] = await Promise.all([ - client.appview<{ feeds: Json[] }>('app.bsky.feed.getActorFeeds', { actor: did, limit: 100 }).catch(() => ({ feeds: [] })), - client.appview<{ lists: Json[] }>('app.bsky.graph.getLists', { actor: did, limit: 100 }).catch(() => ({ lists: [] })), + client.appview<{ feeds: Json[] }>('app.bsky.feed.getActorFeeds', { actor: did, limit: 100 }), + client.appview<{ lists: Json[] }>('app.bsky.graph.getLists', { actor: did, limit: 100 }), ]); return [...feeds.feeds.map((view) => fromView(view, 'feed', 'yours')), ...lists.lists.map((view) => fromView(view, 'list', 'yours'))]; } async function searchCandidates(client: Client, query: string): Promise { - const { feeds } = await client.appview<{ feeds: Json[] }>('app.bsky.unspecced.getPopularFeedGenerators', { query, limit: 50 }).catch(() => ({ feeds: [] as Json[] })); + const { feeds } = await client.appview<{ feeds: Json[] }>('app.bsky.unspecced.getPopularFeedGenerators', { query, limit: 50 }); return feeds.map((view) => fromView(view, 'feed', 'search')); } @@ -214,7 +214,10 @@ export async function resolveFeed(client: Client, ref: string, { kinds = ['feed' // Your own circles first: what you saved, then what you made. let mine: Array = []; if (session) { - const [saved, own] = await Promise.all([savedCandidates(client).catch(() => []), ownCandidates(client, session.did)]); + // A failure to look is said, not taken for having nothing: that would + // send the name off to a search, and answer 'not found' for a feed + // that is right there. + const [saved, own] = await Promise.all([savedCandidates(client), ownCandidates(client, session.did)]); const seen = new Set(); mine = scored(text, [...saved, ...own].filter((candidate) => !seen.has(candidate.uri) && seen.add(candidate.uri)), kinds); const best = mine[0]; diff --git a/src/session.ts b/src/session.ts index 15b4710..217620e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -159,15 +159,21 @@ export class Session { if (expiry !== undefined && expiry - 30 < Date.now() / 1000) await this.refresh(); const headers = { ...options.headers, ...(options.proxy ? { 'atproto-proxy': options.proxy } : {}) }; let refreshed = false; + const origin = new URL(this.pds).origin; for (let attempt = 0; ; attempt++) { + // The nonce this request is sent with. Requests in flight together + // are all refused for want of a new one, and the first answer + // teaches it: each of the others is retried because the nonce now + // known is not the one it was sent with, not because it is new. + const used = this.secret.kind === 'oauth' ? this.secret.dpopNonces[origin] : undefined; try { return await xrpcResponse(this.pds, nsid, { ...options, headers, auth: this.authorizer() }); } catch (error) { if (!(error instanceof XrpcError)) throw error; const errorHeaders = (error as XrpcError & { headers?: Headers }).headers; - if (this.secret.kind === 'oauth' && (error.code === 'use_dpop_nonce' || /use_dpop_nonce/.test(errorHeaders?.get('www-authenticate') ?? '')) - && attempt < 2 && learnNonce(this.secret, xrpcUrl(this.pds, nsid), errorHeaders)) { - continue; + if (this.secret.kind === 'oauth' && (error.code === 'use_dpop_nonce' || /use_dpop_nonce/.test(errorHeaders?.get('www-authenticate') ?? ''))) { + learnNonce(this.secret, xrpcUrl(this.pds, nsid), errorHeaders); + if (attempt < 3 && this.secret.dpopNonces[origin] !== used) continue; } if (isExpired(error) && !refreshed) { refreshed = true; diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 9604cb0..128e77c 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -451,6 +451,22 @@ test('--new shows what was not shown before, and with -n reads the new ones from timeline.length = 0; }); +test('requests in flight together over OAuth all get the new DPoP nonce', async () => { + const bob = did('bob.test'); + (mock as any).feeds = [{ uri: `at://${bob}/app.bsky.feed.generator/news-2-0`, displayName: '📰 Trending News', likeCount: 1, creator: { did: bob, handle: 'bob.test' } }]; + (mock as any).lists = []; + (mock as any).preferences = [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2', items: [{ id: '1', type: 'feed', value: `at://${bob}/app.bsky.feed.generator/news-2-0`, pinned: true }] }]; + // The server rotates its nonce: every request bob's session sends next is refused once. + (mock as any).rotateNonce(); + const { resolveFeed } = await import('../src/feedref.ts'); + const { Client } = await import('../src/api.ts'); + const found = await resolveFeed(new Client('bob.test'), 'news-2-0', { quiet: true }); + assert.equal(found.uri, `at://${bob}/app.bsky.feed.generator/news-2-0`); + assert.equal(found.via, 'saved', 'found among the saved feeds, not by a search after a lost request'); + (mock as any).preferences = []; + (mock as any).feeds = []; +}); + test('records: create, get and delete raw records', async () => { const created = await run('record', 'create', 'com.example.note', '{"text":"hi"}', '--json'); assert.equal(created.code, 0, created.stderr); -- knotmirror 0.0.1