fix(usage): cap Cursor's Retry-After at 24h and honor its HTTP-date form (#28406)

Cursor parsed Retry-After with a bare Number(), so a huge header could block
usage refreshes for years and an HTTP-date was ignored. Claude and Cursor now
both call parseUsageRetryAfterMs, which applies the 24h usage cap to the shared
Retry-After parser, replacing Claude's private copy of the same algorithm.

The shared parser is renamed from relay-retry-after-header/parseRelayRetryAfterMs
to retry-after-header/parseRetryAfterHeaderMs: relay, the updater, mobile and
usage all use it.
This commit is contained in:
Jinwoo Hong
2026-10-13 17:09:06 -04:00
committed by GitHub
parent 0903ed9577
commit 975c27b780
8 changed files with 54 additions and 41 deletions
@@ -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)
@@ -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<OAuthUsageEr
// Why: auth/rate-limit responses are already the user-visible usage API
// answer. Falling through to /usage can spawn Claude Code needlessly.
res.status === 401 || res.status === 403 || res.status === 429,
res.status === 429 ? parseRetryAfterMs(res.headers.get('retry-after')) : null
res.status === 429 ? parseUsageRetryAfterMs(res.headers.get('retry-after')) : null
)
}
function parseRetryAfterMs(header: string | null): number | null {
if (!header) {
return null
}
const seconds = Number(header)
if (Number.isFinite(seconds)) {
return seconds > 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<string> {
if (res.status === 429) {
return 'Claude usage is rate limited right now.'
+25 -9
View File
@@ -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) => {
+4 -4
View File
@@ -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 } : {})
}
})
}
@@ -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)
}
+2 -2
View File
@@ -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 {
+6 -2
View File
@@ -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
}
@@ -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()