mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 16:01:36 +00:00
fix(ai): honor Retry-After when retry-after-ms is unparseable (#111353)
* fix(ai): honor Retry-After when retry-after-ms is unparseable The ChatGPT Responses retry path returned as soon as `retry-after-ms` was present, so a malformed or empty value discarded a valid `Retry-After` header sent alongside it and the client fell back to blind exponential backoff. The sibling parser in `provider-transport-fetch.ts` treats the two headers as ordered preferences and falls through on a parse failure; this aligns the Responses path with that contract. * test(ai): deduplicate retry header coverage --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -98,6 +98,38 @@ describe("streamOpenAICodexResponses retry classification", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "unparseable", retryAfterMs: "not-a-number" },
|
||||
{ label: "empty", retryAfterMs: "" },
|
||||
])("honors retry-after when retry-after-ms is $label", async ({ retryAfterMs }) => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after-ms": retryAfterMs, "retry-after": "7" },
|
||||
}),
|
||||
)
|
||||
.mockRejectedValueOnce(new Error("usage limit: stop after retry delay"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const setTimeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation((callback: TimerHandler) => {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
|
||||
await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: jwt,
|
||||
transport: "sse",
|
||||
}).result();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 7_000);
|
||||
});
|
||||
|
||||
it("does not retry a bodyless 304 response", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
|
||||
@@ -147,12 +147,12 @@ function isRetryableError(status: number, errorText: string): boolean {
|
||||
function resolveHttpRetryDelayMs(response: Response, attempt: number): number {
|
||||
const fallbackMs = BASE_DELAY_MS * 2 ** attempt;
|
||||
const retryAfterMs = response.headers.get("retry-after-ms");
|
||||
if (retryAfterMs !== null) {
|
||||
if (retryAfterMs) {
|
||||
const trimmed = retryAfterMs.trim();
|
||||
const millis = Number(trimmed);
|
||||
return /^\d+(?:\.\d+)?$/.test(trimmed) && Number.isFinite(millis)
|
||||
? (clampTimerTimeoutMs(millis, 0) ?? fallbackMs)
|
||||
: fallbackMs;
|
||||
if (/^\d+(?:\.\d+)?$/.test(trimmed) && Number.isFinite(millis)) {
|
||||
return clampTimerTimeoutMs(millis, 0) ?? fallbackMs;
|
||||
}
|
||||
}
|
||||
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
|
||||
Reference in New Issue
Block a user