···90909191// ─── public API ──────────────────────────────────────────────────────────────
92929393+/**
9494+ * Thrown when the PDS returns 401 on com.atproto.sync.getRepo.
9595+ * Callers can catch this specifically to surface a re-auth prompt rather than
9696+ * treating it as a generic network error.
9797+ */
9898+export class CARFetchUnauthorizedError extends Error {
9999+ constructor(pdsUrl: string, did: string) {
100100+ super(
101101+ `CAR fetch returned 401 Unauthorized for ${did} at ${pdsUrl}. ` +
102102+ `The PDS requires authentication but a valid token could not be obtained. ` +
103103+ `Try signing out and back in to refresh your session.`
104104+ );
105105+ this.name = 'CARFetchUnauthorizedError';
106106+ }
107107+}
108108+93109export interface CARRecord {
94110 rkey: string;
95111 uri: string;
···120136 const response = await fetch(url, { headers, signal });
121137122138 if (!response.ok) {
139139+ if (response.status === 401) {
140140+ throw new CARFetchUnauthorizedError(pdsUrl, did);
141141+ }
123142 throw new Error(`CAR fetch failed: ${response.status} ${response.statusText}`);
124143 }
125144···180199export async function getAgentToken(agent: unknown): Promise<string | undefined> {
181200 const a = agent as Record<string, unknown>;
182201183183- // Password-auth AtpAgent: session carries a plain JWT.
202202+ // Password-auth CredentialSession (AtpAgent):
203203+ // session.accessJwt holds the current JWT. It may be expired — callers
204204+ // should handle CARFetchUnauthorizedError and retry after refreshing.
184205 const jwt = (a['session'] as any)?.accessJwt;
185206 if (jwt) return jwt as string;
186207···191212 const tokens = await sm.getTokens() as { accessToken?: string } | null;
192213 if (tokens?.accessToken) return tokens.accessToken;
193214 } catch {
194194- // If the OAuth token is expired and can't be refreshed silently, fall through.
215215+ // Token read failed — try a silent refresh before giving up.
216216+ }
217217+218218+ // If getTokens() returned nothing (expired session), attempt a silent
219219+ // refresh via the session manager and retry once.
220220+ if (typeof sm?.refresh === 'function') {
221221+ try {
222222+ await sm.refresh();
223223+ const refreshed = await sm.getTokens() as { accessToken?: string } | null;
224224+ if (refreshed?.accessToken) return refreshed.accessToken;
225225+ } catch {
226226+ // Refresh failed — fall through and return undefined.
227227+ }
195228 }
196229 }
197230
+66-7
src/core/sync.ts
···77import type { Agent } from '@atproto/api';
88import type { PlayRecord } from './types.js';
99import { RECORD_TYPE } from './config.js';
1010-import { fetchRepoViaCAR, getPdsUrlFromAgent, getAgentToken } from './car-fetch.js';
1010+import { fetchRepoViaCAR, getPdsUrlFromAgent, getAgentToken, CARFetchUnauthorizedError } from './car-fetch.js';
11111212export interface ExistingRecord {
1313 uri: string;
···4444 signal?.throwIfAborted();
45454646 const pdsUrl = getPdsUrlFromAgent(agent);
4747- const token = await getAgentToken(agent);
4848- const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal, token);
4747+ let token = await getAgentToken(agent);
4848+ let carRecords;
4949+ try {
5050+ carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal, token);
5151+ } catch (err) {
5252+ if (err instanceof CARFetchUnauthorizedError) {
5353+ // The token we sent was invalid or expired. Try to silently refresh the
5454+ // session (works for both CredentialSession / AtpAgent and OAuth agents
5555+ // that expose a refreshSession method on their session manager) then
5656+ // retry the CAR fetch exactly once before giving up.
5757+ const sm = (agent as any)?.sessionManager;
5858+ let retried = false;
5959+ if (typeof sm?.refreshSession === 'function') {
6060+ try {
6161+ await sm.refreshSession();
6262+ const freshToken = await getAgentToken(agent);
6363+ if (freshToken && freshToken !== token) {
6464+ carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal, freshToken);
6565+ token = freshToken;
6666+ retried = true;
6767+ }
6868+ } catch {
6969+ // Refresh or second fetch failed — fall through and throw below.
7070+ }
7171+ }
7272+ if (!retried) {
7373+ // Clear the stale session cache so the next call starts clean.
7474+ sessionCache.delete(did);
7575+ throw err;
7676+ }
7777+ } else {
7878+ throw err;
7979+ }
8080+ }
49815082 const map = new Map<string, ExistingRecord>();
5151- for (const rec of carRecords) {
8383+ for (const rec of carRecords!) {
5284 const value = rec.value as unknown as PlayRecord;
5385 map.set(recordKey(value), { uri: rec.uri, cid: rec.cid, value });
5486 }
···76108 signal?.throwIfAborted();
7710978110 const pdsUrl = getPdsUrlFromAgent(agent);
7979- const token = await getAgentToken(agent);
8080- const carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal, token);
111111+ let token = await getAgentToken(agent);
112112+ let carRecords;
113113+ try {
114114+ carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal, token);
115115+ } catch (err) {
116116+ if (err instanceof CARFetchUnauthorizedError) {
117117+ const sm = (agent as any)?.sessionManager;
118118+ let retried = false;
119119+ if (typeof sm?.refreshSession === 'function') {
120120+ try {
121121+ await sm.refreshSession();
122122+ const freshToken = await getAgentToken(agent);
123123+ if (freshToken && freshToken !== token) {
124124+ carRecords = await fetchRepoViaCAR(pdsUrl, did, RECORD_TYPE, signal, freshToken);
125125+ token = freshToken;
126126+ retried = true;
127127+ }
128128+ } catch {
129129+ // fall through
130130+ }
131131+ }
132132+ if (!retried) {
133133+ sessionCache.delete(did);
134134+ throw err;
135135+ }
136136+ } else {
137137+ throw err;
138138+ }
139139+ }
811408282- const all: ExistingRecord[] = carRecords.map((rec) => ({
141141+ const all: ExistingRecord[] = carRecords!.map((rec) => ({
83142 uri: rec.uri,
84143 cid: rec.cid,
85144 value: rec.value as unknown as PlayRecord,
+18-19
src/lib/publisher.ts
···320320 const rateLimitError = isRateLimitError(err);
321321322322 if (rateLimitError) {
323323- log.warn('⚠️ Rate limit hit (unexpected with proactive pacing) - updating from error headers...');
324324-325325- // Extract and update from error headers
326326- let headers: Record<string, string> | undefined;
327327- if (err?.response?.headers) {
328328- headers = err.response.headers;
329329- } else if (err?.headers) {
330330- headers = err.headers;
331331- }
332332-333333- if (headers && Object.keys(headers).length > 0) {
334334- const normalized = normalizeHeaders(headers);
335335- const hasRateLimitHeaders = Object.keys(normalized).some(k => k.includes('ratelimit'));
336336- if (hasRateLimitHeaders) {
337337- rl.updateFromHeaders(normalized);
338338- }
339339- }
340340-341341- // Wait for permit and retry
323323+ log.warn('⚠️ Rate limit hit — pausing until quota resets…');
324324+325325+ // XRPCError (from @atproto/xrpc) carries response headers directly on
326326+ // err.headers as a plain Record<string, string> built via
327327+ // Object.fromEntries(response.headers.entries()), so keys are already
328328+ // lowercase. There is no err.response property on this error class.
329329+ const errHeaders: Record<string, string> | undefined =
330330+ err?.headers && typeof err.headers === 'object'
331331+ ? (err.headers as Record<string, string>)
332332+ : undefined;
333333+334334+ // handleRateLimitHit zeroes remaining unconditionally — this is the
335335+ // critical fix. Previously we only called updateFromHeaders when
336336+ // headers were present, meaning a headerless 429 left state untouched
337337+ // and waitForPermit returned immediately, sending another request.
338338+ rl.handleRateLimitHit(errHeaders ? normalizeHeaders(errHeaders) : undefined);
339339+340340+ // Now waitForPermit will block until the window resets.
342341 await rl.waitForPermit(batchPoints);
343342 continue;
344343
+39
src/utils/rate-limiter.ts
···686686 }
687687688688 /**
689689+ * Called when the server returns a 429.
690690+ *
691691+ * Zeroes `remaining` unconditionally so the next `waitForPermit` call
692692+ * actually blocks until the window resets, regardless of whether the 429
693693+ * response included rate-limit headers. If headers ARE present they are
694694+ * applied first (so we get an accurate `resetAt`), then remaining is
695695+ * forced to 0.
696696+ *
697697+ * @param errHeaders Optional normalised headers from the 429 error response.
698698+ */
699699+ handleRateLimitHit(errHeaders?: Record<string, string>): void {
700700+ // Apply header info first so resetAt is as accurate as possible.
701701+ if (errHeaders && Object.keys(errHeaders).length > 0) {
702702+ this.updateFromHeaders(errHeaders);
703703+ }
704704+705705+ const now = Math.floor(Date.now() / 1000);
706706+ const state = this.readState();
707707+ if (state) {
708708+ state.remaining = 0;
709709+ state.updatedAt = now;
710710+ this.writeState(state);
711711+ log.warn(`[RateLimiter] 🛑 429 received — zeroed remaining, will wait until ${new Date(state.resetAt * 1000).toISOString()}`);
712712+ } else {
713713+ // No state yet — create a blocking stub with a 60-second reset.
714714+ this.writeState({
715715+ limit: 5000,
716716+ remaining: 0,
717717+ resetAt: now + 60,
718718+ windowSeconds: 3600,
719719+ updatedAt: now,
720720+ headroomThreshold: this.headroomThreshold,
721721+ });
722722+ this.hasLearnedFromServer = true;
723723+ log.warn('[RateLimiter] 🛑 429 received (no prior state) — blocking for 60s');
724724+ }
725725+ }
726726+727727+ /**
689728 * Wait for a permit with the given number of points.
690729 * Combines reserveQuota and waitForReset - loops until permit granted.
691730 *