mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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.'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user