-
-
Notifications
You must be signed in to change notification settings - Fork 377
fix(proxy): retry upstream storage-capacity 400 failures #1345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -354,6 +354,69 @@ describe("ProxyForwarder - retry limit enforcement", () => { | |
| ]); | ||
| }); | ||
|
|
||
| test("upstream storage-capacity 400 should retry, record failure, and switch provider", async () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this suite runs in order, the preceding local-DB test sets AGENTS.md reference: AGENTS.md:L13-L13 Useful? React with 👍 / 👎. |
||
| vi.useFakeTimers(); | ||
|
|
||
| try { | ||
| const session = createSession(); | ||
| const provider1 = createProvider({ | ||
| id: 1, | ||
| name: "storage-limited-provider", | ||
| providerVendorId: null, | ||
| maxRetryAttempts: 2, | ||
| }); | ||
| const provider2 = createProvider({ | ||
| id: 2, | ||
| name: "fallback-provider", | ||
| providerVendorId: null, | ||
| maxRetryAttempts: 2, | ||
| }); | ||
| session.setProvider(provider1); | ||
|
|
||
| const storageError = new ProxyError("invalid request: upstream storage failure", 400, { | ||
| body: '{"error":{"message":"disk storage creation failed: failed to write to temp file; disk free-space floor reached"}}', | ||
| providerId: provider1.id, | ||
| providerName: provider1.name, | ||
| }); | ||
|
|
||
| const selectAlternative = vi.spyOn( | ||
| ProxyForwarder as unknown as { | ||
| selectAlternative: (...args: unknown[]) => unknown; | ||
| }, | ||
| "selectAlternative" | ||
| ); | ||
| selectAlternative.mockResolvedValueOnce(provider2).mockResolvedValueOnce(null); | ||
|
|
||
| const doForward = vi.spyOn( | ||
| ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, | ||
| "doForward" | ||
| ); | ||
| doForward.mockRejectedValueOnce(storageError).mockRejectedValueOnce(storageError); | ||
| doForward.mockResolvedValueOnce( | ||
| new Response("{}", { | ||
| status: 200, | ||
| headers: { "content-type": "application/json", "content-length": "2" }, | ||
| }) | ||
| ); | ||
|
|
||
| const sendPromise = ProxyForwarder.send(session); | ||
| await vi.runAllTimersAsync(); | ||
| const response = await sendPromise; | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(doForward).toHaveBeenCalledTimes(3); | ||
| expect(selectAlternative).toHaveBeenCalledWith(session, [provider1.id]); | ||
| expect(mocks.recordFailure).toHaveBeenCalledTimes(1); | ||
| expect(mocks.recordFailure).toHaveBeenCalledWith(provider1.id, storageError); | ||
| expect(session.provider?.id).toBe(provider2.id); | ||
| expect( | ||
| session.getProviderChain().some((entry) => entry.reason === "client_error_non_retryable") | ||
| ).toBe(false); | ||
| } finally { | ||
| vi.useRealTimers(); | ||
| } | ||
| }); | ||
|
|
||
| test("endpoints > maxRetry: should only use top N lowest-latency endpoints", async () => { | ||
| vi.useFakeTimers(); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| vi.mock("@/repository/error-rules", () => ({ | ||
| getActiveErrorRules: vi.fn(async () => [ | ||
| { | ||
| id: 29, | ||
| pattern: "非法请求|illegal request|invalid request", | ||
| matchType: "regex", | ||
| category: "invalid_request", | ||
| description: "Invalid request format", | ||
| overrideResponse: null, | ||
| overrideStatusCode: null, | ||
| isEnabled: true, | ||
| isDefault: true, | ||
| priority: 50, | ||
| createdAt: new Date(0), | ||
| updatedAt: new Date(0), | ||
| }, | ||
| ]), | ||
| })); | ||
|
|
||
| import { | ||
| categorizeErrorAsync, | ||
| ErrorCategory, | ||
| isRetryableUpstreamStorageCapacityError, | ||
| ProxyError, | ||
| } from "@/app/v1/_lib/proxy/errors"; | ||
|
|
||
| const STORAGE_ERROR_BODY = | ||
| '{"error":{"type":"invalid_request_error","message":"disk storage creation failed: failed to write to temp file; disk free-space floor reached"}}'; | ||
|
|
||
| function createStorageError( | ||
| body: string = STORAGE_ERROR_BODY, | ||
| upstreamError: Record<string, unknown> = {}, | ||
| message = "invalid request: upstream storage failure" | ||
| ): ProxyError { | ||
| return new ProxyError(message, 400, { | ||
| body, | ||
| providerId: 80, | ||
| providerName: "test-provider", | ||
| ...upstreamError, | ||
| }); | ||
| } | ||
|
|
||
| describe("upstream storage-capacity 400 classification", () => { | ||
| it("prioritizes the known provider failure over the broad invalid-request rule", async () => { | ||
| const error = createStorageError(); | ||
|
|
||
| expect(isRetryableUpstreamStorageCapacityError(error)).toBe(true); | ||
| expect(await categorizeErrorAsync(error)).toBe(ErrorCategory.PROVIDER_ERROR); | ||
| }); | ||
|
|
||
| it("keeps ordinary invalid-request 400 responses non-retryable", async () => { | ||
| const error = createStorageError('{"error":{"message":"invalid request: malformed JSON"}}'); | ||
|
|
||
| expect(isRetryableUpstreamStorageCapacityError(error)).toBe(false); | ||
| expect(await categorizeErrorAsync(error)).toBe(ErrorCategory.NON_RETRYABLE_CLIENT_ERROR); | ||
| }); | ||
|
|
||
| it("requires both stable storage markers instead of retrying every 400 with one marker", async () => { | ||
| const error = createStorageError( | ||
| '{"error":{"message":"invalid request: disk storage creation failed"}}' | ||
| ); | ||
|
|
||
| expect(isRetryableUpstreamStorageCapacityError(error)).toBe(false); | ||
| expect(await categorizeErrorAsync(error)).toBe(ErrorCategory.NON_RETRYABLE_CLIENT_ERROR); | ||
| }); | ||
|
|
||
| it("excludes a fake-200 message prefix without relying on inferred status", async () => { | ||
| const error = createStorageError( | ||
| STORAGE_ERROR_BODY.replace("disk storage", "invalid request: disk storage"), | ||
| { | ||
| statusCodeInferred: false, | ||
| }, | ||
| "FAKE_200_JSON_ERROR_NON_EMPTY" | ||
| ); | ||
|
|
||
| expect(isRetryableUpstreamStorageCapacityError(error)).toBe(false); | ||
| expect(await categorizeErrorAsync(error)).toBe(ErrorCategory.NON_RETRYABLE_CLIENT_ERROR); | ||
| }); | ||
|
Brisbanehuang marked this conversation as resolved.
|
||
|
|
||
| it("excludes an inferred status without relying on a fake-200 message prefix", async () => { | ||
| const error = createStorageError( | ||
| STORAGE_ERROR_BODY.replace("disk storage", "invalid request: disk storage"), | ||
| { | ||
| statusCodeInferred: true, | ||
| } | ||
| ); | ||
|
|
||
| expect(isRetryableUpstreamStorageCapacityError(error)).toBe(false); | ||
| expect(await categorizeErrorAsync(error)).toBe(ErrorCategory.NON_RETRYABLE_CLIENT_ERROR); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.