




























@@ -1,5 +1,5 @@
11import type { Model } from "@mariozechner/pi-ai";
2-import { beforeEach, describe, expect, it, vi } from "vitest";
2+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3344const {
55 fetchWithSsrFGuardMock,
@@ -38,6 +38,11 @@ describe("buildGuardedModelFetch", () => {
3838.mockReturnValue({ allowPrivateNetwork: false });
3939delete process.env.OPENCLAW_DEBUG_PROXY_ENABLED;
4040delete process.env.OPENCLAW_DEBUG_PROXY_URL;
41+delete process.env.OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS;
42+});
43+44+afterEach(() => {
45+delete process.env.OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS;
4146});
42474348it("pushes provider capture metadata into the shared guarded fetch seam", async () => {
@@ -93,4 +98,177 @@ describe("buildGuardedModelFetch", () => {
9398proxy: undefined,
9499});
95100});
101+102+describe("long retry-after handling", () => {
103+const anthropicModel = {
104+id: "sonnet-4.6",
105+provider: "anthropic",
106+api: "anthropic-messages",
107+baseUrl: "https://api.anthropic.com/v1",
108+} as unknown as Model<"anthropic-messages">;
109+110+const openaiModel = {
111+id: "gpt-5.4",
112+provider: "openai",
113+api: "openai-responses",
114+baseUrl: "https://api.openai.com/v1",
115+} as unknown as Model<"openai-responses">;
116+117+it("injects x-should-retry:false when a retryable response exceeds the default wait cap", async () => {
118+fetchWithSsrFGuardMock.mockResolvedValue({
119+response: new Response(null, {
120+status: 429,
121+headers: { "retry-after": "239" },
122+}),
123+finalUrl: "https://api.anthropic.com/v1/messages",
124+release: vi.fn(async () => undefined),
125+});
126+127+const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
128+const response = await buildGuardedModelFetch(anthropicModel)(
129+"https://api.anthropic.com/v1/messages",
130+{ method: "POST" },
131+);
132+133+expect(response.status).toBe(429);
134+expect(response.headers.get("retry-after")).toBe("239");
135+expect(response.headers.get("x-should-retry")).toBe("false");
136+});
137+138+it("parses retry-after-ms from OpenAI-compatible responses", async () => {
139+fetchWithSsrFGuardMock.mockResolvedValue({
140+response: new Response(null, {
141+status: 429,
142+headers: { "retry-after-ms": "90000" },
143+}),
144+finalUrl: "https://api.openai.com/v1/responses",
145+release: vi.fn(async () => undefined),
146+});
147+148+const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
149+const response = await buildGuardedModelFetch(openaiModel)(
150+"https://api.openai.com/v1/responses",
151+{ method: "POST" },
152+);
153+154+expect(response.headers.get("x-should-retry")).toBe("false");
155+});
156+157+it("parses HTTP-date retry-after values", async () => {
158+const future = new Date(Date.now() + 120_000).toUTCString();
159+fetchWithSsrFGuardMock.mockResolvedValue({
160+response: new Response(null, {
161+status: 503,
162+headers: { "retry-after": future },
163+}),
164+finalUrl: "https://api.anthropic.com/v1/messages",
165+release: vi.fn(async () => undefined),
166+});
167+168+const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
169+const response = await buildGuardedModelFetch(anthropicModel)(
170+"https://api.anthropic.com/v1/messages",
171+{ method: "POST" },
172+);
173+174+expect(response.headers.get("x-should-retry")).toBe("false");
175+});
176+177+it("respects OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS", async () => {
178+process.env.OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS = "10";
179+fetchWithSsrFGuardMock.mockResolvedValue({
180+response: new Response(null, {
181+status: 429,
182+headers: { "retry-after": "30" },
183+}),
184+finalUrl: "https://api.anthropic.com/v1/messages",
185+release: vi.fn(async () => undefined),
186+});
187+188+const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
189+const response = await buildGuardedModelFetch(anthropicModel)(
190+"https://api.anthropic.com/v1/messages",
191+{ method: "POST" },
192+);
193+194+expect(response.headers.get("x-should-retry")).toBe("false");
195+});
196+197+it("can be disabled with OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS=0", async () => {
198+process.env.OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS = "0";
199+fetchWithSsrFGuardMock.mockResolvedValue({
200+response: new Response(null, {
201+status: 429,
202+headers: { "retry-after": "239" },
203+}),
204+finalUrl: "https://api.anthropic.com/v1/messages",
205+release: vi.fn(async () => undefined),
206+});
207+208+const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
209+const response = await buildGuardedModelFetch(anthropicModel)(
210+"https://api.anthropic.com/v1/messages",
211+{ method: "POST" },
212+);
213+214+expect(response.headers.get("x-should-retry")).toBeNull();
215+});
216+217+it("leaves short retry-after values untouched", async () => {
218+fetchWithSsrFGuardMock.mockResolvedValue({
219+response: new Response(null, {
220+status: 429,
221+headers: { "retry-after": "30" },
222+}),
223+finalUrl: "https://api.anthropic.com/v1/messages",
224+release: vi.fn(async () => undefined),
225+});
226+227+const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
228+const response = await buildGuardedModelFetch(anthropicModel)(
229+"https://api.anthropic.com/v1/messages",
230+{ method: "POST" },
231+);
232+233+expect(response.headers.get("x-should-retry")).toBeNull();
234+});
235+236+it("ignores malformed retry-after values", async () => {
237+fetchWithSsrFGuardMock.mockResolvedValue({
238+response: new Response(null, {
239+status: 429,
240+headers: { "retry-after": "soon" },
241+}),
242+finalUrl: "https://api.anthropic.com/v1/messages",
243+release: vi.fn(async () => undefined),
244+});
245+246+const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
247+const response = await buildGuardedModelFetch(anthropicModel)(
248+"https://api.anthropic.com/v1/messages",
249+{ method: "POST" },
250+);
251+252+expect(response.headers.get("x-should-retry")).toBeNull();
253+});
254+255+it("ignores retry-after on non-retryable responses", async () => {
256+fetchWithSsrFGuardMock.mockResolvedValue({
257+response: new Response(null, {
258+status: 400,
259+headers: { "retry-after": "239" },
260+}),
261+finalUrl: "https://api.anthropic.com/v1/messages",
262+release: vi.fn(async () => undefined),
263+});
264+265+const { buildGuardedModelFetch } = await import("./provider-transport-fetch.js");
266+const response = await buildGuardedModelFetch(anthropicModel)(
267+"https://api.anthropic.com/v1/messages",
268+{ method: "POST" },
269+);
270+271+expect(response.headers.get("x-should-retry")).toBeNull();
272+});
273+});
96274});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。