Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/utils/src/translate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { translateText } from './translate';

describe('translate retry behavior', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('should wait for Retry-After before retrying a 429 response', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 429,
headers: new Headers({ 'Retry-After': '60' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => [[['hola']]],
});

vi.stubGlobal('fetch', fetchMock);

const result = translateText('hello', 'es');
await Promise.resolve();

expect(fetchMock).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(1000);
expect(fetchMock).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(59000);

await expect(result).resolves.toBe('hola');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
43 changes: 40 additions & 3 deletions packages/utils/src/translate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,45 @@ const RETRY_CONFIG = {
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/**
* Calculate retry delay time with exponential backoff
* Parse Retry-After header value.
* @param retryAfter Retry-After header value
* @returns Delay time in milliseconds, or null if the header is invalid
*/
const parseRetryAfterDelay = (retryAfter: string | null): number | null => {
if (!retryAfter) {
return null;
}

const retryAfterValue = retryAfter.trim();
if (!retryAfterValue) {
return null;
}

const retryAfterSeconds = Number(retryAfterValue);
if (Number.isFinite(retryAfterSeconds)) {
return retryAfterSeconds >= 0 ? retryAfterSeconds * 1000 : null;
}

const retryAfterDate = Date.parse(retryAfterValue);
if (!Number.isNaN(retryAfterDate)) {
return Math.max(retryAfterDate - Date.now(), 0);
}

return null;
};
Comment on lines 17 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for timeout configurations or retry budget limits that might bound this behavior

# Search for timeout configurations in the codebase
rg -n -C3 'timeout|TimeoutError|AbortSignal' --type=ts --glob '!node_modules'

# Search for any retry budget or circuit breaker patterns
rg -n -C3 'retry.*budget|circuit.*breaker|max.*wait|timeout.*config' --type=ts --glob '!node_modules'

Repository: refly-ai/refly

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "parseRetryAfterDelay|RETRY_CONFIG|maxDelay|Retry-After" packages/utils/src/translate/index.ts
echo "---- context around RETRY_CONFIG ----"
rg -n "RETRY_CONFIG" packages/utils/src/translate/index.ts -n
echo "---- printing translate index.ts head (1-200) ----"
sed -n '1,220p' packages/utils/src/translate/index.ts

Repository: refly-ai/refly

Length of output: 8596


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "parseRetryAfterDelay\(" packages/utils/src/translate/index.ts
rg -n "calculateRetryDelay\(" packages/utils/src/translate/index.ts
rg -n "maxDelay" packages/utils/src/translate/index.ts

Repository: refly-ai/refly

Length of output: 348


Cap parsed Retry-After delay to RETRY_CONFIG.maxDelay

calculateRetryDelay returns the parsed Retry-After value as-is when valid, bypassing the Math.min(..., RETRY_CONFIG.maxDelay) cap that’s applied only to the exponential-backoff path—so Retry-After: 3600 (or a far-future HTTP date) can produce waits far longer than the configured 10s. Consider return Math.min(retryAfterDelay, RETRY_CONFIG.maxDelay) (or document intentional unbounded behavior).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/utils/src/translate/index.ts` around lines 17 - 43, The
parseRetryAfterDelay function currently returns parsed Retry-After delays
unbounded, allowing values larger than the configured cap; update
parseRetryAfterDelay (used by calculateRetryDelay) to clamp returned delays to
RETRY_CONFIG.maxDelay by returning Math.min(parsedDelay, RETRY_CONFIG.maxDelay)
for both numeric-second and HTTP-date branches so Retry-After values cannot
exceed the global maxDelay.


/**
* Calculate retry delay time with Retry-After or exponential backoff
* @param attempt Retry attempt number (starting from 0)
* @param retryAfter Retry-After header value
* @returns Delay time in milliseconds
*/
const calculateRetryDelay = (attempt: number): number => {
const calculateRetryDelay = (attempt: number, retryAfter?: string | null): number => {
const retryAfterDelay = parseRetryAfterDelay(retryAfter ?? null);
if (retryAfterDelay !== null) {
return retryAfterDelay;
}

const delayTime = RETRY_CONFIG.baseDelay * RETRY_CONFIG.backoffMultiplier ** attempt;
return Math.min(delayTime, RETRY_CONFIG.maxDelay);
};
Expand Down Expand Up @@ -85,7 +119,10 @@ const fetchWithRetry = async (url: string, options?: RequestInit): Promise<Respo
(response.status === 429 || response.status >= 500) &&
attempt < RETRY_CONFIG.maxRetries
) {
const retryDelay = calculateRetryDelay(attempt);
const retryDelay = calculateRetryDelay(
attempt,
response.status === 429 ? response.headers.get('Retry-After') : null,
);
console.warn(
`Translation request failed with status ${response.status}, retrying in ${retryDelay}ms (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1})`,
);
Expand Down