orama/sdk/tests/unit/http/network-errors-bug-129.test.ts
anonpenguin23 6f5b2db95e fix(sdk): surface typed SDKError on network/timeout failures (#129)
The HTTP client re-threw the raw platform error on a transport failure, so
callers (e.g. AnChat's JwtSession driving client.auth.refresh/challenge) could
only tell "couldn't reach the gateway" from a real HTTP error by string-
matching `TypeError: Network request failed`. The typed SDKError was built
only for the onNetworkError callback, never for the thrown error, and native
errors weren't retryable so they bubbled raw.

normalizeError() now maps a fetch failure -> SDKError{code:"NETWORK_ERROR",
httpStatus:0} and a timeout AbortError -> {code:"TIMEOUT",httpStatus:0}, and
that typed error is thrown to the caller. Genuine HTTP errors pass through
unchanged. httpStatus:0 is the stable "no HTTP response received" signal to
branch on; the original platform message is preserved for diagnostics.

Deliberately NOT auto-retrying network failures: a blind retry on a non-
idempotent POST like /v1/auth/refresh could burn the rotated refresh token on
a lost response. The SDK now just gives the app a typed error to drive its own
retry/failover.

Tests: tests/unit/http/network-errors-bug-129.test.ts (TypeError->NETWORK_ERROR,
AbortError->TIMEOUT, real 401 passes through, callback gets the typed error).
Full unit suite green, typecheck clean.
2026-06-12 16:44:37 +03:00

89 lines
3.1 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { HttpClient } from "../../../src/core/http";
import { SDKError } from "../../../src/errors";
/**
* Bugboard #129 — typed network errors.
*
* Before this fix the HttpClient re-threw the raw platform error on a
* network-level failure, so a caller (e.g. AnChat's JwtSession) could only
* tell "couldn't reach the gateway" apart from a real HTTP error by
* string-matching `TypeError: Network request failed`. These guards lock in
* that every transport failure surfaces as a typed SDKError with httpStatus 0
* and a stable `.code`, while genuine HTTP errors keep their status/code.
*/
describe("Bug #129 — HttpClient surfaces typed network errors", () => {
function client(fetchImpl: typeof fetch, onNetworkError?: any) {
return new HttpClient({
baseURL: "https://gw.example",
maxRetries: 0,
timeout: 5000,
fetch: fetchImpl,
onNetworkError,
});
}
it("maps a fetch TypeError (connection failure) to SDKError NETWORK_ERROR / status 0", async () => {
const fetchSpy = vi.fn(async () => {
throw new TypeError("Network request failed");
});
const err = await client(fetchSpy as any)
.post("/v1/auth/refresh", { refresh_token: "x" })
.catch((e) => e);
expect(err).toBeInstanceOf(SDKError);
expect(err.code).toBe("NETWORK_ERROR");
expect(err.httpStatus).toBe(0);
// Original platform message is preserved for diagnostics.
expect(err.message).toContain("Network request failed");
});
it("maps an AbortError (timeout) to SDKError TIMEOUT / status 0", async () => {
const fetchSpy = vi.fn(async () => {
const e = new Error("aborted");
e.name = "AbortError";
throw e;
});
const err = await client(fetchSpy as any)
.get("/v1/auth/challenge")
.catch((e) => e);
expect(err).toBeInstanceOf(SDKError);
expect(err.code).toBe("TIMEOUT");
expect(err.httpStatus).toBe(0);
expect(err.message).toContain("5000ms");
});
it("passes a real HTTP error through unchanged (not masked as NETWORK_ERROR)", async () => {
const fetchSpy = vi.fn(
async () =>
new Response(JSON.stringify({ error: "nope", code: "BAD_TOKEN" }), {
status: 401,
headers: { "content-type": "application/json" },
})
);
const err = await client(fetchSpy as any)
.post("/v1/auth/refresh", { refresh_token: "x" })
.catch((e) => e);
expect(err).toBeInstanceOf(SDKError);
expect(err.httpStatus).toBe(401);
expect(err.code).toBe("BAD_TOKEN");
});
it("hands the typed error (not the raw TypeError) to the onNetworkError callback", async () => {
const seen: SDKError[] = [];
const fetchSpy = vi.fn(async () => {
throw new TypeError("Failed to fetch");
});
await client(fetchSpy as any, (e: SDKError) => seen.push(e))
.get("/v1/db/read")
.catch(() => {});
expect(seen).toHaveLength(1);
expect(seen[0]).toBeInstanceOf(SDKError);
expect(seen[0].code).toBe("NETWORK_ERROR");
expect(seen[0].httpStatus).toBe(0);
});
});