From 67b34ba24b1475f58d8960d569df37f76a9f68af Mon Sep 17 00:00:00 2001 From: HwangJohn Date: Tue, 14 Jul 2026 22:04:43 +0900 Subject: [PATCH] fix(onboard): recover custom endpoint DNS failures Signed-off-by: HwangJohn --- .../inference-selection-validation.test.ts | 36 +++++++++++++++++++ src/lib/validation-recovery.test.ts | 19 ++++++++++ src/lib/validation-recovery.ts | 7 +++- src/lib/validation.ts | 7 ++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index 7d4c6ba37b5..8c32c071e41 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -160,6 +160,42 @@ describe("inference selection validation", () => { } }); + it("offers transport recovery when custom endpoint DNS preflight fails (#6854)", async () => { + const probeOpenAiLikeEndpoint = vi.fn(() => ({ ok: true, api: "openai-completions" })); + const promptValidationRecovery = vi.fn(async () => "retry" as const); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "test-key", + probeOpenAiLikeEndpoint, + promptValidationRecovery, + resolveEndpointHost: async () => { + throw new Error("getaddrinfo ENOTFOUND example.invalid"); + }, + }); + + try { + await expect( + helpers.validateCustomOpenAiLikeSelection( + "Custom endpoint", + "https://example.invalid/v1", + "model-a", + "COMPATIBLE_API_KEY", + ), + ).resolves.toEqual({ ok: false, retry: "retry" }); + expect(probeOpenAiLikeEndpoint).not.toHaveBeenCalled(); + expect(promptValidationRecovery).toHaveBeenCalledWith( + "Custom endpoint", + expect.objectContaining({ kind: "transport", retry: "retry" }), + "COMPATIBLE_API_KEY", + null, + ); + } finally { + error.mockRestore(); + } + }); + it.each([ "http://127.0.0.1:8000/v1", "https://inference.local/v1", diff --git a/src/lib/validation-recovery.test.ts b/src/lib/validation-recovery.test.ts index cae2d6e5159..c90d9cdcfba 100644 --- a/src/lib/validation-recovery.test.ts +++ b/src/lib/validation-recovery.test.ts @@ -19,6 +19,11 @@ describe("validation-recovery helpers", () => { expect( getTransportRecoveryMessage({ curlStatus: 6, message: "Could not resolve host" }), ).toContain("could not resolve"); + expect( + getTransportRecoveryMessage({ + message: 'cannot resolve endpoint host "example.invalid": getaddrinfo ENOTFOUND', + }), + ).toContain("Check DNS, VPN, or the endpoint URL"); expect( getTransportRecoveryMessage({ curlStatus: 60, message: "SSL certificate problem" }), ).toContain("TLS/certificate"); @@ -44,6 +49,20 @@ describe("validation-recovery helpers", () => { }); }); + it("routes DNS-backed preflight resolution failures through transport recovery (#6854)", () => { + const failure = { + name: "SSRF preflight", + httpStatus: 0, + curlStatus: 0, + message: 'cannot resolve endpoint host "example.invalid": getaddrinfo ENOTFOUND', + }; + expect(getProbeRecovery({ failures: [failure] })).toEqual({ + kind: "transport", + retry: "retry", + failure, + }); + }); + it("only allows model-specific retry when explicitly enabled", () => { const probe = { failures: [{ httpStatus: 400, message: "unknown model" }] }; expect(getProbeRecovery(probe)).toEqual({ kind: "unknown", retry: "selection" }); diff --git a/src/lib/validation-recovery.ts b/src/lib/validation-recovery.ts index f82a1dea17b..b93946f64c9 100644 --- a/src/lib/validation-recovery.ts +++ b/src/lib/validation-recovery.ts @@ -32,7 +32,12 @@ export function getTransportRecoveryMessage(failure: ValidationFailureLike = {}) if (failure.httpStatus && failure.httpStatus >= 500 && failure.httpStatus < 600) { return " The provider endpoint is reachable but currently failing upstream."; } - if (failure.curlStatus === 6 || /could not resolve host|name or service not known/.test(text)) { + if ( + failure.curlStatus === 6 || + /cannot resolve endpoint host|did not resolve to any address|could not resolve host|name or service not known|enotfound|eai_again/.test( + text, + ) + ) { return " Validation could not resolve the provider hostname. Check DNS, VPN, or the endpoint URL."; } if (failure.curlStatus === 7 || /connection refused|failed to connect/.test(text)) { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index bfca2559b73..a5c468737f8 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -86,6 +86,13 @@ export function classifyValidationFailure({ if (/unauthorized|forbidden|invalid api key|invalid_auth|permission/i.test(normalized)) { return { kind: "credential", retry: "credential" }; } + if ( + /cannot resolve endpoint host|did not resolve to any address|could not resolve host|name or service not known|enotfound|eai_again|no http response/i.test( + normalized, + ) + ) { + return { kind: "transport", retry: "retry" }; + } if (/ssl|tls|certificate|handshake/i.test(normalized)) { return { kind: "transport", retry: "retry" }; }