Compare commits

...

1 commit

Author SHA1 Message Date
GuaGua
f6e6217af1 fix(translate): honor 429 rate limit and add queue cooldown 2026-04-06 23:10:17 -07:00
3 changed files with 202 additions and 11 deletions

View file

@ -44,6 +44,10 @@ export async function aiTranslate(
return finalTranslation
}
catch (error) {
if (error instanceof Error) {
throw error
}
throw new Error(extractAISDKErrorMessage(error))
}
}

View file

@ -426,7 +426,92 @@ describe("requestQueue retry with timeout combined", () => {
})
})
// 11. Reconfigure the request queue
// 11. Retry policy helpers
describe("requestQueue retry policy", () => {
it("does not retry non-retryable status errors", async () => {
vi.useFakeTimers()
let attempts = 0
const q = new RequestQueue({
...baseConfig,
maxRetries: 2,
baseRetryDelayMs: 100,
})
const error = Object.assign(new Error("Bad Request"), {
statusCode: 400,
isRetryable: false,
})
const failingThunk = () => {
attempts++
return Promise.reject(error)
}
const promise = q.enqueue(failingThunk, Date.now(), "non-retryable")
promise.catch(() => {})
await vi.advanceTimersByTimeAsync(1000)
expect(attempts).toBe(1)
await expect(promise).rejects.toBe(error)
})
it("applies a queue-wide cooldown for 429 responses", async () => {
vi.useFakeTimers()
const q = new RequestQueue({
...baseConfig,
rate: 1,
capacity: 1,
maxRetries: 1,
baseRetryDelayMs: 100,
})
const completed: string[] = []
let firstAttempts = 0
const rateLimitedError = Object.assign(new Error("Too Many Requests"), {
statusCode: 429,
isRetryable: true,
responseHeaders: {
"retry-after": "2",
},
})
const firstThunk = () => {
firstAttempts++
if (firstAttempts === 1) {
return Promise.reject(rateLimitedError)
}
completed.push("retried")
return Promise.resolve("retried")
}
const secondThunk = () => {
completed.push("next")
return Promise.resolve("next")
}
const firstPromise = q.enqueue(firstThunk, Date.now(), "first")
const secondPromise = q.enqueue(secondThunk, Date.now(), "second")
await vi.advanceTimersByTimeAsync(1999)
expect(completed).toEqual([])
await vi.advanceTimersByTimeAsync(1)
expect(completed).toEqual(["next"])
await vi.advanceTimersByTimeAsync(1000)
expect(completed).toEqual(["next", "retried"])
await expect(firstPromise).resolves.toBe("retried")
await expect(secondPromise).resolves.toBe("next")
})
})
// 12. Reconfigure the request queue
describe("requestQueue reconfigure the request queue", () => {
it("increase the request rate", async () => {
vi.useFakeTimers()

View file

@ -22,6 +22,98 @@ export interface QueueOptions {
baseRetryDelayMs: number
}
interface RetryAwareError {
message?: unknown
statusCode?: unknown
isRetryable?: unknown
responseHeaders?: unknown
}
function getRetryAwareError(error: unknown): RetryAwareError | undefined {
return typeof error === "object" && error !== null ? error as RetryAwareError : undefined
}
function getStatusCode(error: unknown): number | undefined {
const statusCode = getRetryAwareError(error)?.statusCode
return typeof statusCode === "number" ? statusCode : undefined
}
function getMessage(error: unknown): string {
const message = getRetryAwareError(error)?.message
return typeof message === "string" ? message : ""
}
function getHeaderValue(error: unknown, key: string): string | undefined {
const headers = getRetryAwareError(error)?.responseHeaders
if (!headers) {
return undefined
}
if (headers instanceof Headers) {
return headers.get(key) ?? headers.get(key.toLowerCase()) ?? undefined
}
if (typeof headers === "object" && headers !== null) {
const normalizedKey = key.toLowerCase()
const entry = Object.entries(headers).find(([headerKey]) => headerKey.toLowerCase() === normalizedKey)
const value = entry?.[1]
return typeof value === "string" ? value : undefined
}
return undefined
}
function getRetryAfterMs(error: unknown, fallbackMs: number): number {
const retryAfterMs = getHeaderValue(error, "retry-after-ms")
if (retryAfterMs) {
const timeoutMs = Number.parseFloat(retryAfterMs)
if (!Number.isNaN(timeoutMs) && timeoutMs >= 0 && timeoutMs < 60_000) {
return Math.max(timeoutMs, fallbackMs)
}
}
const retryAfter = getHeaderValue(error, "retry-after")
if (retryAfter) {
const timeoutSeconds = Number.parseFloat(retryAfter)
if (!Number.isNaN(timeoutSeconds) && timeoutSeconds >= 0 && timeoutSeconds < 60) {
return Math.max(timeoutSeconds * 1000, fallbackMs)
}
const parsedMs = Date.parse(retryAfter) - Date.now()
if (!Number.isNaN(parsedMs) && parsedMs >= 0 && parsedMs < 60_000) {
return Math.max(parsedMs, fallbackMs)
}
}
return fallbackMs
}
const RATE_LIMIT_ERROR_REGEX = /too many requests|rate[ -]?limit/i
function isRateLimitError(error: unknown): boolean {
const statusCode = getStatusCode(error)
if (statusCode === 429) {
return true
}
const message = getMessage(error)
return RATE_LIMIT_ERROR_REGEX.test(message)
}
function isRetryableError(error: unknown): boolean {
const retryable = getRetryAwareError(error)?.isRetryable
if (typeof retryable === "boolean") {
return retryable
}
const statusCode = getStatusCode(error)
if (statusCode != null) {
return statusCode === 408 || statusCode === 409 || statusCode === 429 || statusCode >= 500
}
return true
}
export class RequestQueue {
private waitingQueue: BinaryHeapPQ<RequestTask & { hash: string }>
private waitingTasks = new Map<string, RequestTask>()
@ -31,6 +123,7 @@ export class RequestQueue {
// token bucket
private bucketTokens: number
private lastRefill: number
private cooldownUntil = 0
constructor(private options: QueueOptions) {
this.options = options
@ -89,8 +182,13 @@ export class RequestQueue {
this.refillTokens()
while (this.bucketTokens >= 1 && this.waitingQueue.size() > 0) {
const now = Date.now()
if (now < this.cooldownUntil) {
break
}
const task = this.waitingQueue.peek()
if (task && task.scheduleAt <= Date.now()) {
if (task && task.scheduleAt <= now) {
this.waitingQueue.pop()
this.waitingTasks.delete(task.hash)
this.executingTasks.set(task.hash, task)
@ -113,7 +211,8 @@ export class RequestQueue {
const now = Date.now()
const delayUntilScheduled = Math.max(0, nextTask.scheduleAt - now)
const msUntilNextToken = this.bucketTokens >= 1 ? 0 : Math.ceil((1 - this.bucketTokens) / this.options.rate * 1000)
const delay = Math.max(delayUntilScheduled, msUntilNextToken)
const msUntilCooldownEnds = Math.max(0, this.cooldownUntil - now)
const delay = Math.max(delayUntilScheduled, msUntilNextToken, msUntilCooldownEnds)
this.nextScheduleTimer = setTimeout(() => {
this.nextScheduleTimer = null
@ -161,16 +260,19 @@ export class RequestQueue {
// console.error(`❌ Task ${task.id} failed at ${Date.now()}:`, error)
const nextRetryCount = task.retryCount + 1
const baseBackoffDelayMs = this.options.baseRetryDelayMs * (2 ** (nextRetryCount - 1))
const delayWithoutJitter = getRetryAfterMs(error, baseBackoffDelayMs)
const jitter = isRateLimitError(error) ? 0 : Math.random() * 0.1 * delayWithoutJitter
const delayMs = delayWithoutJitter + jitter
if (isRateLimitError(error)) {
this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + delayMs)
}
// Check if we should retry
if (task.retryCount < this.options.maxRetries) {
task.retryCount++
// Calculate exponential backoff delay
const backoffDelayMs = this.options.baseRetryDelayMs * (2 ** (task.retryCount - 1))
// Add some jitter to prevent thundering herd
const jitter = Math.random() * 0.1 * backoffDelayMs
const delayMs = backoffDelayMs + jitter
if (task.retryCount < this.options.maxRetries && isRetryableError(error)) {
task.retryCount = nextRetryCount
// Schedule retry
const retryAt = Date.now() + delayMs