Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/friendly-agents-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@voltagent/core": patch
---

Honor provider Retry-After headers when retrying failed model calls.
76 changes: 76 additions & 0 deletions packages/core/src/agent/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3689,6 +3689,82 @@ Use pandas and summarize findings.`.split("\n"),
}
});

it("should honor Retry-After when retrying provider rate limits", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");

let resolveRetry!: () => void;
const retrySeen = new Promise<void>((resolve) => {
resolveRetry = resolve;
});
const onRetry = vi.fn(() => {
resolveRetry();
});
const agent = new Agent({
name: "RetryAfterAgent",
instructions: "Test",
model: mockModel as any,
maxRetries: 1,
hooks: createHooks({ onRetry }),
});

const mockResponse = {
text: "Retry response",
content: [{ type: "text", text: "Retry response" }],
reasoning: [],
files: [],
sources: [],
toolCalls: [],
toolResults: [],
finishReason: "stop",
usage: {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
},
warnings: [],
request: {},
response: {
id: "retry-response",
modelId: "test-model",
timestamp: new Date(),
messages: [],
},
steps: [],
};

let callCount = 0;
vi.mocked(ai.generateText).mockImplementation(async () => {
callCount += 1;
if (callCount === 1) {
const error = new Error("Rate limited");
(error as any).isRetryable = true;
(error as any).statusCode = 429;
(error as any).headers = new Headers({ "retry-after": "3" });
throw error;
}
return mockResponse as any;
});

const resultPromise = agent.generateText("Test");

try {
await retrySeen;
await Promise.resolve();

expect(onRetry).toHaveBeenCalledTimes(1);
expect(vi.mocked(ai.generateText)).toHaveBeenCalledTimes(1);
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 3000);

await vi.advanceTimersByTimeAsync(3000);
await expect(resultPromise).resolves.toMatchObject({ text: "Retry response" });
expect(vi.mocked(ai.generateText)).toHaveBeenCalledTimes(2);
} finally {
setTimeoutSpy.mockRestore();
vi.useRealTimers();
}
});

it("should handle model errors gracefully", async () => {
const agent = new Agent({
name: "TestAgent",
Expand Down
27 changes: 26 additions & 1 deletion packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5738,6 +5738,30 @@ export class Agent {
return true;
}

private getRetryAfterDelayMs(error: unknown): number | undefined {
const headers = (error as { headers?: Headers | Record<string, string> } | undefined)?.headers;
const retryAfter =
headers instanceof Headers
? headers.get("retry-after")
: (headers?.["retry-after"] ?? headers?.["Retry-After"]);

if (!retryAfter) {
return undefined;
}

const seconds = Number.parseInt(retryAfter, 10);
if (Number.isFinite(seconds) && seconds > 0) {
return seconds * 1000;
}

const retryAt = Date.parse(retryAfter);
if (Number.isFinite(retryAt)) {
return Math.max(retryAt - Date.now(), 0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

return undefined;
}

private async executeWithModelFallback<T>({
oc,
operation,
Expand Down Expand Up @@ -5885,7 +5909,8 @@ export class Agent {
const canRetry = retryEligible && !isLastAttempt;

if (canRetry) {
const retryDelayMs = Math.min(1000 * 2 ** attemptIndex, 10000);
const retryDelayMs =
this.getRetryAfterDelayMs(error) ?? Math.min(1000 * 2 ** attemptIndex, 10000);
logger.debug(`[Agent:${this.name}] - Model attempt failed, retrying`, {
operation,
modelName,
Expand Down