mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 09:47:53 +00:00
fix: ignore invalid Retry-After HTTP dates (#102987)
* fix: ignore invalid Retry-After HTTP dates * fix(ai): centralize strict Retry-After dates --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
co-authored by
Peter Steinberger
Peter Steinberger
parent
2b3dc3042f
commit
2fd0f88f62
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseRetryAfterHttpDateMs } from "./retry-after.js";
|
||||
|
||||
const EXAMPLE_TIMESTAMP = Date.UTC(1994, 10, 6, 8, 49, 37);
|
||||
|
||||
describe("parseRetryAfterHttpDateMs", () => {
|
||||
it.each([
|
||||
["IMF-fixdate", "Sun, 06 Nov 1994 08:49:37 GMT"],
|
||||
["RFC 850", "Sunday, 06-Nov-94 08:49:37 GMT"],
|
||||
["asctime single-digit day", "Sun Nov 6 08:49:37 1994"],
|
||||
["asctime two-digit day", "Sun Nov 06 08:49:37 1994"],
|
||||
])("parses %s", (_label, value) => {
|
||||
expect(parseRetryAfterHttpDateMs(value, Date.UTC(1994, 0, 1))).toBe(EXAMPLE_TIMESTAMP);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Sun, 31 Feb 2027 00:00:00 GMT",
|
||||
"Sunday, 31-Feb-27 00:00:00 GMT",
|
||||
"Sun Feb 31 00:00:00 2027",
|
||||
"Thu, 29 Feb 2027 00:00:00 GMT",
|
||||
])("rejects an invalid calendar date: %s", (value) => {
|
||||
expect(parseRetryAfterHttpDateMs(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Mon, 06 Nov 1994 08:49:37 GMT",
|
||||
"Monday, 06-Nov-94 08:49:37 GMT",
|
||||
"Mon Nov 6 08:49:37 1994",
|
||||
])("rejects a weekday that does not match the date: %s", (value) => {
|
||||
expect(parseRetryAfterHttpDateMs(value, Date.UTC(1994, 0, 1))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts the HTTP-date leap-second range", () => {
|
||||
expect(parseRetryAfterHttpDateMs("Sat, 31 Dec 2016 23:59:60 GMT")).toBe(Date.UTC(2017, 0, 1));
|
||||
});
|
||||
|
||||
it("uses the RFC 850 rolling 50-year rule before validating the weekday", () => {
|
||||
const now = Date.UTC(2026, 10, 6);
|
||||
expect(parseRetryAfterHttpDateMs("Sunday, 06-Nov-50 00:00:00 GMT", now)).toBe(
|
||||
Date.UTC(2050, 10, 6),
|
||||
);
|
||||
expect(parseRetryAfterHttpDateMs("Sunday, 06-Nov-77 00:00:00 GMT", now)).toBe(
|
||||
Date.UTC(1977, 10, 6),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"sun, 06 Nov 1994 08:49:37 GMT",
|
||||
"Sun, 06 Nov 1899 08:49:37 GMT",
|
||||
"Sun, 06 Nov 1994 24:00:00 GMT",
|
||||
"Sun, 06 Nov 1994 08:60:00 GMT",
|
||||
"Sun, 06 Nov 1994 08:49:61 GMT",
|
||||
"Sun, 6 Nov 1994 08:49:37 GMT",
|
||||
"Sun Nov 6 08:49:37 1994",
|
||||
])("rejects a value outside the HTTP-date grammar: %s", (value) => {
|
||||
expect(parseRetryAfterHttpDateMs(value)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
const HTTP_DATE_MONTH_INDEX = new Map(
|
||||
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].map(
|
||||
(month, index) => [month, index],
|
||||
),
|
||||
);
|
||||
const HTTP_DATE_SHORT_WEEKDAY_INDEX = new Map(
|
||||
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((weekday, index) => [weekday, index]),
|
||||
);
|
||||
const HTTP_DATE_LONG_WEEKDAY_INDEX = new Map(
|
||||
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].map(
|
||||
(weekday, index) => [weekday, index],
|
||||
),
|
||||
);
|
||||
|
||||
const IMF_FIXDATE_RE =
|
||||
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
||||
const OBSOLETE_RFC850_DATE_RE =
|
||||
/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
||||
const OBSOLETE_ASCTIME_DATE_RE =
|
||||
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{2}| \d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
|
||||
|
||||
type HttpDateComponents = {
|
||||
weekday: number | undefined;
|
||||
year: number;
|
||||
month: number | undefined;
|
||||
day: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
/** Parses the three HTTP-date forms accepted for Retry-After without Date.parse normalization. */
|
||||
export function parseRetryAfterHttpDateMs(value: string, nowMs = Date.now()): number | undefined {
|
||||
const imfFixdate = IMF_FIXDATE_RE.exec(value);
|
||||
if (imfFixdate) {
|
||||
return parseHttpDateComponentsMs({
|
||||
weekday: HTTP_DATE_SHORT_WEEKDAY_INDEX.get(imfFixdate[1] ?? ""),
|
||||
year: Number.parseInt(imfFixdate[4] ?? "", 10),
|
||||
month: HTTP_DATE_MONTH_INDEX.get(imfFixdate[3] ?? ""),
|
||||
day: Number.parseInt(imfFixdate[2] ?? "", 10),
|
||||
hours: Number.parseInt(imfFixdate[5] ?? "", 10),
|
||||
minutes: Number.parseInt(imfFixdate[6] ?? "", 10),
|
||||
seconds: Number.parseInt(imfFixdate[7] ?? "", 10),
|
||||
});
|
||||
}
|
||||
|
||||
const rfc850Date = OBSOLETE_RFC850_DATE_RE.exec(value);
|
||||
if (rfc850Date) {
|
||||
const now = new Date(nowMs);
|
||||
if (Number.isNaN(now.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
const shortYear = Number.parseInt(rfc850Date[4] ?? "", 10);
|
||||
const candidateYear = Math.floor(now.getUTCFullYear() / 100) * 100 + shortYear;
|
||||
const components = {
|
||||
weekday: HTTP_DATE_LONG_WEEKDAY_INDEX.get(rfc850Date[1] ?? ""),
|
||||
month: HTTP_DATE_MONTH_INDEX.get(rfc850Date[3] ?? ""),
|
||||
day: Number.parseInt(rfc850Date[2] ?? "", 10),
|
||||
hours: Number.parseInt(rfc850Date[5] ?? "", 10),
|
||||
minutes: Number.parseInt(rfc850Date[6] ?? "", 10),
|
||||
seconds: Number.parseInt(rfc850Date[7] ?? "", 10),
|
||||
};
|
||||
const candidate = parseHttpDateCalendarMs({ year: candidateYear, ...components });
|
||||
if (candidate === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
// RFC 9110 resolves two-digit years against the current century, then rolls
|
||||
// dates more than 50 years ahead back by 100 years.
|
||||
const fiftyYearsFromNow = Date.UTC(
|
||||
now.getUTCFullYear() + 50,
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
now.getUTCHours(),
|
||||
now.getUTCMinutes(),
|
||||
now.getUTCSeconds(),
|
||||
now.getUTCMilliseconds(),
|
||||
);
|
||||
const resolvedYear = candidate > fiftyYearsFromNow ? candidateYear - 100 : candidateYear;
|
||||
return parseHttpDateComponentsMs({ year: resolvedYear, ...components });
|
||||
}
|
||||
|
||||
const asctimeDate = OBSOLETE_ASCTIME_DATE_RE.exec(value);
|
||||
if (asctimeDate) {
|
||||
return parseHttpDateComponentsMs({
|
||||
weekday: HTTP_DATE_SHORT_WEEKDAY_INDEX.get(asctimeDate[1] ?? ""),
|
||||
year: Number.parseInt(asctimeDate[7] ?? "", 10),
|
||||
month: HTTP_DATE_MONTH_INDEX.get(asctimeDate[2] ?? ""),
|
||||
day: Number.parseInt((asctimeDate[3] ?? "").trim(), 10),
|
||||
hours: Number.parseInt(asctimeDate[4] ?? "", 10),
|
||||
minutes: Number.parseInt(asctimeDate[5] ?? "", 10),
|
||||
seconds: Number.parseInt(asctimeDate[6] ?? "", 10),
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseHttpDateComponentsMs(components: HttpDateComponents): number | undefined {
|
||||
const timestamp = parseHttpDateCalendarMs(components);
|
||||
if (timestamp === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const weekdayTimestamp = components.seconds === 60 ? timestamp - 1_000 : timestamp;
|
||||
if (new Date(weekdayTimestamp).getUTCDay() !== components.weekday) {
|
||||
return undefined;
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function parseHttpDateCalendarMs(
|
||||
components: Omit<HttpDateComponents, "weekday">,
|
||||
): number | undefined {
|
||||
const { year, month, day, hours, minutes, seconds } = components;
|
||||
if (
|
||||
month === undefined ||
|
||||
!Number.isInteger(year) ||
|
||||
year < 1900 ||
|
||||
!Number.isInteger(day) ||
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
!Number.isInteger(hours) ||
|
||||
hours < 0 ||
|
||||
hours > 23 ||
|
||||
!Number.isInteger(minutes) ||
|
||||
minutes < 0 ||
|
||||
minutes > 59 ||
|
||||
!Number.isInteger(seconds) ||
|
||||
seconds < 0 ||
|
||||
seconds > 60
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const calendarSecond = Math.min(seconds, 59);
|
||||
// JavaScript Date has no :60 representation. Validate the stated calendar
|
||||
// second against :59, then advance to the leap-second instant.
|
||||
const timestamp = Date.UTC(year, month, day, hours, minutes, calendarSecond);
|
||||
const parsedDate = new Date(timestamp);
|
||||
if (
|
||||
parsedDate.getUTCFullYear() !== year ||
|
||||
parsedDate.getUTCMonth() !== month ||
|
||||
parsedDate.getUTCDate() !== day ||
|
||||
parsedDate.getUTCHours() !== hours ||
|
||||
parsedDate.getUTCMinutes() !== minutes ||
|
||||
parsedDate.getUTCSeconds() !== calendarSecond
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return seconds === 60 ? timestamp + 1_000 : timestamp;
|
||||
}
|
||||
@@ -822,44 +822,121 @@ describe("streamOpenAICodexResponses transport", () => {
|
||||
expect(payload).toMatchObject({ prompt_cache_key: "stable-cache-key" });
|
||||
});
|
||||
|
||||
it.each(["1.5", "0x10"])(
|
||||
"ignores invalid Retry-After header delay values: %s",
|
||||
async (retryAfter) => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after": retryAfter },
|
||||
}),
|
||||
)
|
||||
.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>;
|
||||
});
|
||||
|
||||
const stream = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-1",
|
||||
},
|
||||
it.each([
|
||||
"1.5",
|
||||
"0x10",
|
||||
"Sun, 31 Feb 2027 00:00:00 GMT",
|
||||
"Sunday, 31-Feb-27 00:00:00 GMT",
|
||||
"Mon, 06 Nov 1994 08:49:37 GMT",
|
||||
"Monday, 06-Nov-94 08:49:37 GMT",
|
||||
])("ignores invalid Retry-After header delay values: %s", async (retryAfter) => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after": retryAfter },
|
||||
}),
|
||||
transport: "sse",
|
||||
)
|
||||
.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>;
|
||||
});
|
||||
|
||||
const result = await stream.result();
|
||||
const stream = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-1",
|
||||
},
|
||||
}),
|
||||
transport: "sse",
|
||||
});
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
|
||||
},
|
||||
);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
|
||||
});
|
||||
|
||||
it("honors retry-after-ms ahead of Retry-After", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after-ms": "1250", "retry-after": "9" },
|
||||
}),
|
||||
)
|
||||
.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>;
|
||||
});
|
||||
|
||||
const stream = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-1",
|
||||
},
|
||||
}),
|
||||
transport: "sse",
|
||||
});
|
||||
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1250);
|
||||
});
|
||||
|
||||
it("honors RFC 850 Retry-After years within the 50-year future window", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-11-06T00:00:00.000Z"));
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after": "Sunday, 06-Nov-50 00:00:00 GMT" },
|
||||
}),
|
||||
)
|
||||
.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>;
|
||||
});
|
||||
|
||||
const stream = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-1",
|
||||
},
|
||||
}),
|
||||
transport: "sse",
|
||||
});
|
||||
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("caps oversized Retry-After delays before sleeping", async () => {
|
||||
const fetchMock = vi
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { getEnvApiKey } from "../env-api-keys.js";
|
||||
import { getAiTransportHost, resolveAiTransportHeaderSentinels } from "../host.js";
|
||||
import { parseRetryAfterHttpDateMs } from "../internal/retry-after.js";
|
||||
import { registerSessionResourceCleanup } from "../session-resources.js";
|
||||
import type {
|
||||
Api,
|
||||
@@ -75,8 +76,6 @@ const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 1000;
|
||||
const REQUEST_COMPRESSION_ZSTD_LEVEL = 3;
|
||||
const RETRY_AFTER_HTTP_DATE_RE =
|
||||
/^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/;
|
||||
const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "opencode"]);
|
||||
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
|
||||
const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
|
||||
@@ -428,10 +427,10 @@ export const streamOpenAICodexResponses: StreamFunction<
|
||||
const seconds = Number(trimmedRetryAfter);
|
||||
if (/^\d+$/.test(trimmedRetryAfter) && Number.isFinite(seconds)) {
|
||||
delayMs = clampTimerTimeoutMs(seconds * 1000, 0) ?? delayMs;
|
||||
} else if (RETRY_AFTER_HTTP_DATE_RE.test(trimmedRetryAfter)) {
|
||||
const date = Date.parse(trimmedRetryAfter);
|
||||
if (!Number.isNaN(date)) {
|
||||
delayMs = clampTimerTimeoutMs(date - Date.now(), 0) ?? delayMs;
|
||||
} else {
|
||||
const retryAt = parseRetryAfterHttpDateMs(trimmedRetryAfter);
|
||||
if (retryAt !== undefined) {
|
||||
delayMs = clampTimerTimeoutMs(retryAt - Date.now(), 0) ?? delayMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1981,6 +1981,50 @@ describe("buildGuardedModelFetch", () => {
|
||||
expect(response.headers.get("x-should-retry")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Sun, 31 Feb 2027 00:00:00 GMT",
|
||||
"Sunday, 31-Feb-27 00:00:00 GMT",
|
||||
"Mon, 06 Nov 1994 08:49:37 GMT",
|
||||
"Monday, 06-Nov-94 08:49:37 GMT",
|
||||
])("ignores invalid HTTP-date retry-after values: %s", async (retryAfter) => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(null, {
|
||||
status: 503,
|
||||
headers: { "retry-after": retryAfter },
|
||||
}),
|
||||
finalUrl: "https://api.anthropic.com/v1/messages",
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const response = await buildGuardedModelFetch(anthropicModel)(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
expect(response.headers.get("x-should-retry")).toBeNull();
|
||||
});
|
||||
|
||||
it("interprets RFC 850 retry-after years within the 50-year future window", async () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-11-06T00:00:00.000Z"));
|
||||
try {
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(null, {
|
||||
status: 503,
|
||||
headers: { "retry-after": "Sunday, 06-Nov-50 00:00:00 GMT" },
|
||||
}),
|
||||
finalUrl: "https://api.anthropic.com/v1/messages",
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const response = await buildGuardedModelFetch(anthropicModel)(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
expect(response.headers.get("x-should-retry")).toBe("false");
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("respects OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS", async () => {
|
||||
process.env.OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS = "10";
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Applies request timeouts, proxy/TLS overrides, SSRF policy, local-service leases, retry hints, and SSE normalization.
|
||||
*/
|
||||
import { parseRetryAfterHttpDateMs } from "@openclaw/ai/internal/retry-after";
|
||||
import {
|
||||
isCloudMetadataIpAddress,
|
||||
isLinkLocalIpAddress,
|
||||
@@ -66,15 +67,6 @@ const SSE_SANITIZE_BUFFER_MAX_CHARS = 16 * 1024 * 1024;
|
||||
|
||||
const BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS = new Set(["instance-data"]);
|
||||
const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/;
|
||||
const RETRY_AFTER_HTTP_DATE_RE =
|
||||
/^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/;
|
||||
const HTTP_DATE_MONTH_INDEX = new Map(
|
||||
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].map(
|
||||
(month, index) => [month, index],
|
||||
),
|
||||
);
|
||||
const OBSOLETE_ASCTIME_HTTP_DATE_RE =
|
||||
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
|
||||
|
||||
function hasReadableSseData(block: string): boolean {
|
||||
const dataLines = block
|
||||
@@ -483,60 +475,14 @@ function parseRetryAfterSeconds(headers: Headers): number | undefined {
|
||||
return parseStrictNonNegativeInteger(trimmedRetryAfterSeconds) ?? Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
const trimmedRetryAfter = trimmedRetryAfterSeconds;
|
||||
if (!RETRY_AFTER_HTTP_DATE_RE.test(trimmedRetryAfter)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const retryAt = parseRetryAfterHttpDateMs(trimmedRetryAfter);
|
||||
if (Number.isNaN(retryAt)) {
|
||||
const retryAt = parseRetryAfterHttpDateMs(trimmedRetryAfterSeconds);
|
||||
if (retryAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.max(0, (retryAt - Date.now()) / 1000);
|
||||
}
|
||||
|
||||
function parseRetryAfterHttpDateMs(value: string): number {
|
||||
const match = OBSOLETE_ASCTIME_HTTP_DATE_RE.exec(value);
|
||||
if (match) {
|
||||
const month = HTTP_DATE_MONTH_INDEX.get(match[1] ?? "");
|
||||
if (month === undefined) {
|
||||
return Number.NaN;
|
||||
}
|
||||
const year = Number.parseInt(match[6] ?? "", 10);
|
||||
const day = Number.parseInt((match[2] ?? "").trim(), 10);
|
||||
const hours = Number.parseInt(match[3] ?? "", 10);
|
||||
const minutes = Number.parseInt(match[4] ?? "", 10);
|
||||
const seconds = Number.parseInt(match[5] ?? "", 10);
|
||||
if (
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
hours > 23 ||
|
||||
minutes > 59 ||
|
||||
seconds > 59 ||
|
||||
[year, day, hours, minutes, seconds].some((component) => !Number.isFinite(component))
|
||||
) {
|
||||
return Number.NaN;
|
||||
}
|
||||
const timestamp = Date.UTC(year, month, day, hours, minutes, seconds);
|
||||
const parsedDate = new Date(timestamp);
|
||||
return parsedDate.getUTCFullYear() === year &&
|
||||
parsedDate.getUTCMonth() === month &&
|
||||
parsedDate.getUTCDate() === day &&
|
||||
parsedDate.getUTCHours() === hours &&
|
||||
parsedDate.getUTCMinutes() === minutes &&
|
||||
parsedDate.getUTCSeconds() === seconds
|
||||
? timestamp
|
||||
: Number.NaN;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
function resolveMaxSdkRetryWaitSeconds(): number | undefined {
|
||||
const raw = process.env.OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS?.trim();
|
||||
if (!raw) {
|
||||
|
||||
@@ -45,6 +45,10 @@ const workspacePackageAliasEntries = {
|
||||
srcFile: "src/internal/openai.ts",
|
||||
distFile: "dist/internal/openai.mjs",
|
||||
},
|
||||
"internal/retry-after": {
|
||||
srcFile: "src/internal/retry-after.ts",
|
||||
distFile: "dist/internal/retry-after.mjs",
|
||||
},
|
||||
"internal/runtime": {
|
||||
srcFile: "src/internal/runtime.ts",
|
||||
distFile: "dist/internal/runtime.mjs",
|
||||
|
||||
@@ -507,6 +507,7 @@ describe("plugin-sdk root alias", () => {
|
||||
it("keeps AI runtime transitive package imports on the source graph", () => {
|
||||
const packageRoot = path.dirname(path.dirname(path.dirname(rootAliasPath)));
|
||||
const sourcePaths = {
|
||||
aiRetryAfter: path.join(packageRoot, "packages", "ai", "src", "internal", "retry-after.ts"),
|
||||
aiRuntime: path.join(packageRoot, "packages", "ai", "src", "internal", "runtime.ts"),
|
||||
codeSpans: path.join(packageRoot, "packages", "markdown-core", "src", "code-spans.ts"),
|
||||
fences: path.join(packageRoot, "packages", "markdown-core", "src", "fences.ts"),
|
||||
@@ -525,6 +526,7 @@ describe("plugin-sdk root alias", () => {
|
||||
|
||||
expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded");
|
||||
const aliasMap = (lazyModule.createJitiOptions.at(-1)?.alias ?? {}) as Record<string, string>;
|
||||
expect(aliasMap["@openclaw/ai/internal/retry-after"]).toBe(sourcePaths.aiRetryAfter);
|
||||
expect(aliasMap["@openclaw/ai/internal/runtime"]).toBe(sourcePaths.aiRuntime);
|
||||
expect(aliasMap["@openclaw/markdown-core/code-spans"]).toBe(sourcePaths.codeSpans);
|
||||
expect(aliasMap["@openclaw/markdown-core/fences"]).toBe(sourcePaths.fences);
|
||||
@@ -570,6 +572,7 @@ describe("plugin-sdk root alias", () => {
|
||||
"@openclaw/ai/validation",
|
||||
"@openclaw/ai/internal/anthropic",
|
||||
"@openclaw/ai/internal/openai",
|
||||
"@openclaw/ai/internal/retry-after",
|
||||
"@openclaw/ai/internal/runtime",
|
||||
"@openclaw/ai/internal/shared",
|
||||
"@openclaw/markdown-core",
|
||||
|
||||
@@ -400,6 +400,11 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
|
||||
"ai",
|
||||
path.join("internal", "runtime.ts"),
|
||||
);
|
||||
const aiRetryAfterSource = writeInternalCorePackageSource(
|
||||
root,
|
||||
"ai",
|
||||
path.join("internal", "retry-after.ts"),
|
||||
);
|
||||
const acpCoreSource = writeInternalCorePackageSource(
|
||||
root,
|
||||
"acp-core",
|
||||
@@ -421,6 +426,7 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
|
||||
expect(installedAliases).toContain("@openclaw/normalization-core/boolean-coercion");
|
||||
expect(installedAliases).toContain("@openclaw/media-core/mime");
|
||||
expect(installedAliases).toContain("@openclaw/markdown-core/code-spans");
|
||||
expect(installedAliases).toContain("@openclaw/ai/internal/retry-after");
|
||||
expect(installedAliases).toContain("@openclaw/ai/internal/runtime");
|
||||
expect(installedAliases).toContain("@openclaw/acp-core/runtime/types");
|
||||
expect(installedAliases).toContain("@openclaw/llm-core");
|
||||
@@ -440,6 +446,9 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
|
||||
expect(
|
||||
fs.realpathSync(requireFromCoreSource.resolve("@openclaw/markdown-core/code-spans")),
|
||||
).toBe(fs.realpathSync(markdownCoreSource));
|
||||
expect(
|
||||
fs.realpathSync(requireFromCoreSource.resolve("@openclaw/ai/internal/retry-after")),
|
||||
).toBe(fs.realpathSync(aiRetryAfterSource));
|
||||
expect(fs.realpathSync(requireFromCoreSource.resolve("@openclaw/ai/internal/runtime"))).toBe(
|
||||
fs.realpathSync(aiRuntimeSource),
|
||||
);
|
||||
@@ -455,6 +464,7 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
|
||||
).toThrow();
|
||||
expect(() => requireFromPlugin.resolve("@openclaw/media-core/mime")).toThrow();
|
||||
expect(() => requireFromPlugin.resolve("@openclaw/markdown-core/code-spans")).toThrow();
|
||||
expect(() => requireFromPlugin.resolve("@openclaw/ai/internal/retry-after")).toThrow();
|
||||
expect(() => requireFromPlugin.resolve("@openclaw/ai/internal/runtime")).toThrow();
|
||||
expect(() => requireFromPlugin.resolve("@openclaw/acp-core/runtime/types")).toThrow();
|
||||
expect(() => requireFromPlugin.resolve("@openclaw/llm-core")).toThrow();
|
||||
|
||||
@@ -96,6 +96,7 @@ const INTERNAL_CORE_PACKAGE_ALIASES = [
|
||||
["validation", "validation.ts"],
|
||||
["internal/anthropic", path.join("internal", "anthropic.ts")],
|
||||
["internal/openai", path.join("internal", "openai.ts")],
|
||||
["internal/retry-after", path.join("internal", "retry-after.ts")],
|
||||
["internal/runtime", path.join("internal", "runtime.ts")],
|
||||
["internal/shared", path.join("internal", "shared.ts")],
|
||||
],
|
||||
|
||||
@@ -22,6 +22,7 @@ const config = {
|
||||
validation: "packages/ai/src/validation.ts",
|
||||
"internal/anthropic": "packages/ai/src/internal/anthropic.ts",
|
||||
"internal/openai": "packages/ai/src/internal/openai.ts",
|
||||
"internal/retry-after": "packages/ai/src/internal/retry-after.ts",
|
||||
"internal/runtime": "packages/ai/src/internal/runtime.ts",
|
||||
"internal/shared": "packages/ai/src/internal/shared.ts",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user