diff --git a/mobile/src/transport/mobile-relay-resume-director.ts b/mobile/src/transport/mobile-relay-resume-director.ts index 56813133757..9f6135c9927 100644 --- a/mobile/src/transport/mobile-relay-resume-director.ts +++ b/mobile/src/transport/mobile-relay-resume-director.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { parseRelayRetryAfterMs } from '../../../src/shared/relay-retry-after-header' +import { parseRetryAfterHeaderMs } from '../../../src/shared/retry-after-header' import { MOBILE_RELAY_RETRY_AFTER_MAX_MS } from './mobile-relay-retry-delays' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' @@ -55,7 +55,10 @@ export async function resolveMobileRelayEndpoint(args: { if (!response.ok) { throw new RelayDirectorHttpError( response.status, - parseRelayRetryAfterMs(response.headers.get('retry-after'), MOBILE_RELAY_RETRY_AFTER_MAX_MS) + parseRetryAfterHeaderMs( + response.headers.get('retry-after'), + MOBILE_RELAY_RETRY_AFTER_MAX_MS + ) ) } const declaredLength = Number(response.headers.get('content-length') ?? 0) diff --git a/src/main/rate-limits/claude-oauth-usage-error.ts b/src/main/rate-limits/claude-oauth-usage-error.ts index 1ccb1471de6..58cb569a0e8 100644 --- a/src/main/rate-limits/claude-oauth-usage-error.ts +++ b/src/main/rate-limits/claude-oauth-usage-error.ts @@ -1,5 +1,4 @@ -// Why: a corrupt/hostile Retry-After must not gate usage refreshes for days. -const MAX_RETRY_AFTER_MS = 24 * 60 * 60 * 1000 +import { parseUsageRetryAfterMs } from './usage-retry-after' export class OAuthUsageError extends Error { constructor( @@ -19,27 +18,10 @@ export async function createOAuthUsageError(res: Response): Promise 0 ? Math.min(seconds * 1000, MAX_RETRY_AFTER_MS) : null - } - // Why: Retry-After may also be an HTTP-date (RFC 9110). - const dateMs = Date.parse(header) - if (!Number.isFinite(dateMs)) { - return null - } - const delta = dateMs - Date.now() - return delta > 0 ? Math.min(delta, MAX_RETRY_AFTER_MS) : null -} - async function describeOAuthUsageError(res: Response): Promise { if (res.status === 429) { return 'Claude usage is rate limited right now.' diff --git a/src/main/rate-limits/cursor-fetcher.test.ts b/src/main/rate-limits/cursor-fetcher.test.ts index a075c37db99..7d74d814091 100644 --- a/src/main/rate-limits/cursor-fetcher.test.ts +++ b/src/main/rate-limits/cursor-fetcher.test.ts @@ -108,16 +108,32 @@ describe('fetchCursorRateLimits', () => { expect(limits.error).toBe('Unable to read the Cursor CLI auth file') }) - it('treats a rejected session as expired sign-in and records Retry-After on 429', async () => { - netFetchMock.mockResolvedValueOnce(jsonResponse({}, 401)) - expect( - (await fetchCursorRateLimits({ authReadResult: session() })).usageMetadata?.failureKind - ).toBe('stale-token') + it('treats a rejected session as expired sign-in and records a capped Retry-After on 429', async () => { + vi.useFakeTimers({ now: Date.parse('2026-10-13T12:00:00Z') }) + try { + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 401)) + expect( + (await fetchCursorRateLimits({ authReadResult: session() })).usageMetadata?.failureKind + ).toBe('stale-token') - netFetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '60' })) - const limited = await fetchCursorRateLimits({ authReadResult: session() }) - expect(limited.usageMetadata?.failureKind).toBe('rate-limited') - expect(limited.usageMetadata?.retryAtMs).toBeGreaterThan(Date.now()) + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '60' })) + const limited = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limited.usageMetadata?.failureKind).toBe('rate-limited') + expect(limited.usageMetadata?.retryAtMs).toBe(Date.now() + 60_000) + + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '99999999' })) + const capped = await fetchCursorRateLimits({ authReadResult: session() }) + expect(capped.usageMetadata?.retryAtMs).toBe(Date.now() + 24 * 60 * 60 * 1000) + + const retryAt = Date.now() + 5 * 60_000 + netFetchMock.mockResolvedValueOnce( + jsonResponse({}, 429, { 'retry-after': new Date(retryAt).toUTCString() }) + ) + const dated = await fetchCursorRateLimits({ authReadResult: session() }) + expect(dated.usageMetadata?.retryAtMs).toBe(retryAt) + } finally { + vi.useRealTimers() + } }) it.each([403, 302, 304])('reports HTTP %i without claiming the login expired', async (status) => { diff --git a/src/main/rate-limits/cursor-fetcher.ts b/src/main/rate-limits/cursor-fetcher.ts index 615e62aa882..ea3f05fd105 100644 --- a/src/main/rate-limits/cursor-fetcher.ts +++ b/src/main/rate-limits/cursor-fetcher.ts @@ -13,6 +13,7 @@ import { mapCursorUsageSummary, parseCursorUsageSummary } from './cursor-usage-mapping' +import { parseUsageRetryAfterMs } from './usage-retry-after' const DASHBOARD_ORIGIN = 'https://cursor.com' const USAGE_SUMMARY_URL = `${DASHBOARD_ORIGIN}/api/usage-summary` @@ -101,7 +102,8 @@ async function fetchDashboardJson( } } if (res.status === 429) { - const retryAfterSeconds = Number(res.headers.get('retry-after')) + const nowMs = Date.now() + const retryAfterMs = parseUsageRetryAfterMs(res.headers.get('retry-after'), nowMs) return { kind: 'result', result: result('error', 'Cursor usage is rate limited right now', { @@ -110,9 +112,7 @@ async function fetchDashboardJson( credentialSource: session.source, authProvenance: provenance, failureKind: 'rate-limited', - ...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 - ? { retryAtMs: Date.now() + retryAfterSeconds * 1000 } - : {}) + ...(retryAfterMs !== null ? { retryAtMs: nowMs + retryAfterMs } : {}) } }) } diff --git a/src/main/rate-limits/usage-retry-after.ts b/src/main/rate-limits/usage-retry-after.ts new file mode 100644 index 00000000000..878b61de15e --- /dev/null +++ b/src/main/rate-limits/usage-retry-after.ts @@ -0,0 +1,8 @@ +import { parseRetryAfterHeaderMs } from '../../shared/retry-after-header' + +// Why: a corrupt/hostile Retry-After must not gate usage refreshes for days. +const MAX_USAGE_RETRY_AFTER_MS = 24 * 60 * 60 * 1000 + +export function parseUsageRetryAfterMs(value: string | null, nowMs?: number): number | null { + return parseRetryAfterHeaderMs(value, MAX_USAGE_RETRY_AFTER_MS, nowMs) +} diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index cb0fc519f9d..297a9c1e513 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { z } from 'zod' import type { E2EEKeypair } from '../e2ee-keypair' import { cancelUnreadResponseBody } from '../../lib/unread-response-body' -import { parseRelayRetryAfterMs } from '../../../shared/relay-retry-after-header' +import { parseRetryAfterHeaderMs } from '../../../shared/retry-after-header' import { RelayAssignAbortedError, RelayAssignRateLimitedError, @@ -66,7 +66,7 @@ export class RelayAssignLocallyPacedError extends RelayHttpError { } function relayRetryAfterMs(value: string | null): number | null { - return parseRelayRetryAfterMs(value, RELAY_RETRY_AFTER_MAX_MS) + return parseRetryAfterHeaderMs(value, RELAY_RETRY_AFTER_MAX_MS) } export function shouldRetryRelayConnectionError(error: unknown): boolean { diff --git a/src/main/updater-release-builds.ts b/src/main/updater-release-builds.ts index 216d86c68cb..6513e68398f 100644 --- a/src/main/updater-release-builds.ts +++ b/src/main/updater-release-builds.ts @@ -9,7 +9,7 @@ import { type ReleaseBuild, type ReleaseChannel } from '../shared/release-channel' -import { parseRelayRetryAfterMs } from '../shared/relay-retry-after-header' +import { parseRetryAfterHeaderMs } from '../shared/retry-after-header' import { getGhRateLimitBlockedUntilMs, recordGhPrimaryRateLimit } from './git/gh-rate-limit-breaker' import { isValidVersion } from './updater-fallback' import { rejectReleaseApiToken, resolveReleaseApiToken } from './updater-release-api-token' @@ -61,7 +61,11 @@ function isPrimaryRateLimited(res: Response): boolean { * Retry-After arrives as seconds or an HTTP date; the reset epoch is the fallback. */ export function rateLimitResetAtMs(headers: Headers, nowMs: number): number | null { - const retryAfterMs = parseRelayRetryAfterMs(headers.get('retry-after'), RETRY_AFTER_MAX_MS, nowMs) + const retryAfterMs = parseRetryAfterHeaderMs( + headers.get('retry-after'), + RETRY_AFTER_MAX_MS, + nowMs + ) if (retryAfterMs !== null) { return nowMs + retryAfterMs } diff --git a/src/shared/relay-retry-after-header.ts b/src/shared/retry-after-header.ts similarity index 78% rename from src/shared/relay-retry-after-header.ts rename to src/shared/retry-after-header.ts index 039c59e5512..438ca93f5e0 100644 --- a/src/shared/relay-retry-after-header.ts +++ b/src/shared/retry-after-header.ts @@ -1,6 +1,6 @@ -// Relay director Retry-After: delta-seconds or HTTP-date. Null means the server +// Retry-After (RFC 9110): delta-seconds or HTTP-date. Null means the server // asked for no pacing, so the caller keeps its own backoff. -export function parseRelayRetryAfterMs( +export function parseRetryAfterHeaderMs( value: string | null | undefined, maxMs: number, nowMs = Date.now()