mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-04-30 01:56:46 +00:00
Compare commits
1 commit
main
...
fix/1289-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e6217af1 |
3 changed files with 202 additions and 11 deletions
|
|
@ -44,6 +44,10 @@ export async function aiTranslate(
|
|||
return finalTranslation
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new Error(extractAISDKErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue