orama/sdk/src/core/http.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

573 lines
17 KiB
TypeScript

import { SDKError } from "../errors";
/**
* Context provided to the onNetworkError callback
*/
export interface NetworkErrorContext {
method: "GET" | "POST" | "PUT" | "DELETE" | "WS";
path: string;
isRetry: boolean;
attempt: number;
}
/**
* Callback invoked when a network error occurs.
* Use this to trigger gateway failover or other error handling.
*/
export type NetworkErrorCallback = (
error: SDKError,
context: NetworkErrorContext
) => void;
export interface HttpClientConfig {
baseURL: string;
timeout?: number;
maxRetries?: number;
retryDelayMs?: number;
fetch?: typeof fetch;
/**
* Enable debug logging (includes full SQL queries and args). Default: false
*/
debug?: boolean;
/**
* Callback invoked on network errors (after all retries exhausted).
* Use this to trigger gateway failover at the application layer.
*/
onNetworkError?: NetworkErrorCallback;
}
/**
* Create a fetch function with proper TLS configuration for staging certificates
* In Node.js, we need to configure TLS to accept Let's Encrypt staging certificates
*/
function createFetchWithTLSConfig(): typeof fetch {
// Check if we're in a Node.js environment
if (typeof process !== "undefined" && process.versions?.node) {
// For testing/staging/development: allow staging certificates
// Let's Encrypt staging certificates are self-signed and not trusted by default
const isDevelopmentOrStaging =
process.env.NODE_ENV !== "production" ||
process.env.DEBROS_ALLOW_STAGING_CERTS === "true" ||
process.env.DEBROS_USE_HTTPS === "true";
if (isDevelopmentOrStaging) {
// Allow self-signed/staging certificates
// WARNING: Only use this in development/testing environments
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
}
return globalThis.fetch;
}
export class HttpClient {
private baseURL: string;
private timeout: number;
private maxRetries: number;
private retryDelayMs: number;
private fetch: typeof fetch;
private apiKey?: string;
private jwt?: string;
private debug: boolean;
private onNetworkError?: NetworkErrorCallback;
constructor(config: HttpClientConfig) {
this.baseURL = config.baseURL.replace(/\/$/, "");
this.timeout = config.timeout ?? 60000;
this.maxRetries = config.maxRetries ?? 3;
this.retryDelayMs = config.retryDelayMs ?? 1000;
// Use provided fetch or create one with proper TLS configuration for staging certificates
this.fetch = config.fetch ?? createFetchWithTLSConfig();
this.debug = config.debug ?? false;
this.onNetworkError = config.onNetworkError;
}
/**
* Set the network error callback
*/
setOnNetworkError(callback: NetworkErrorCallback | undefined): void {
this.onNetworkError = callback;
}
setApiKey(apiKey?: string) {
this.apiKey = apiKey;
// Don't clear JWT - allow both to coexist
}
setJwt(jwt?: string) {
this.jwt = jwt;
// Don't clear API key - allow both to coexist
if (typeof console !== "undefined") {
console.log(
"[HttpClient] JWT set:",
!!jwt,
"API key still present:",
!!this.apiKey
);
}
}
private getAuthHeaders(path: string): Record<string, string> {
const headers: Record<string, string> = {};
// For database, pubsub, proxy, and cache operations, ONLY use API key to avoid JWT user context
// interfering with namespace-level authorization
const isDbOperation = path.includes("/v1/rqlite/");
const isPubSubOperation = path.includes("/v1/pubsub/");
const isProxyOperation = path.includes("/v1/proxy/");
const isCacheOperation = path.includes("/v1/cache/");
// For auth operations, prefer API key over JWT to ensure proper authentication
const isAuthOperation = path.includes("/v1/auth/");
if (
isDbOperation ||
isPubSubOperation ||
isProxyOperation ||
isCacheOperation
) {
// For database/pubsub/proxy/cache operations: use only API key (preferred for namespace operations)
if (this.apiKey) {
headers["X-API-Key"] = this.apiKey;
} else if (this.jwt) {
// Fallback to JWT if no API key
headers["Authorization"] = `Bearer ${this.jwt}`;
}
} else if (isAuthOperation) {
// For auth operations: prefer API key over JWT (auth endpoints should use explicit API key)
if (this.apiKey) {
headers["X-API-Key"] = this.apiKey;
}
if (this.jwt) {
headers["Authorization"] = `Bearer ${this.jwt}`;
}
} else {
// For other operations: send both JWT and API key
if (this.jwt) {
headers["Authorization"] = `Bearer ${this.jwt}`;
}
if (this.apiKey) {
headers["X-API-Key"] = this.apiKey;
}
}
return headers;
}
private getAuthToken(): string | undefined {
return this.jwt || this.apiKey;
}
getApiKey(): string | undefined {
return this.apiKey;
}
/**
* Get the base URL
*/
getBaseURL(): string {
return this.baseURL;
}
/**
* Normalize any thrown error into a typed SDKError so callers can branch on
* `.code`/`.httpStatus` instead of string-matching a bare platform
* `TypeError: Network request failed` (bugboard #129).
*
* - SDKError (an HTTP error response) passes through unchanged.
* - An AbortError (our own per-request timeout firing) → code "TIMEOUT".
* - Anything else (fetch rejects with a TypeError on DNS failure, connection
* refused, offline, or TLS error) → code "NETWORK_ERROR".
*
* In every network case httpStatus is 0 (no HTTP response was received), which
* is how the app distinguishes "couldn't reach the gateway" from a real 4xx/5xx.
*/
private normalizeError(error: unknown, timeoutMs: number): SDKError {
if (error instanceof SDKError) {
return error;
}
const name = (error as { name?: string })?.name;
const message = error instanceof Error ? error.message : String(error);
if (name === "AbortError") {
return new SDKError(
`request timed out after ${timeoutMs}ms`,
0,
"TIMEOUT",
{ cause: name }
);
}
return new SDKError(
message || "network request failed",
0,
"NETWORK_ERROR",
{ cause: name }
);
}
async request<T = any>(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
options: {
body?: any;
headers?: Record<string, string>;
query?: Record<string, string | number | boolean>;
timeout?: number; // Per-request timeout override
} = {}
): Promise<T> {
const startTime = performance.now(); // Track request start time
const url = new URL(this.baseURL + path);
if (options.query) {
Object.entries(options.query).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.getAuthHeaders(path),
...options.headers,
};
const controller = new AbortController();
const requestTimeout = options.timeout ?? this.timeout; // Use override or default
const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
const fetchOptions: RequestInit = {
method,
headers,
signal: controller.signal,
};
if (options.body !== undefined) {
fetchOptions.body = JSON.stringify(options.body);
}
// Extract and log SQL query details for rqlite operations
const isRqliteOperation = path.includes("/v1/rqlite/");
let queryDetails: string | null = null;
if (isRqliteOperation && options.body) {
try {
const body =
typeof options.body === "string"
? JSON.parse(options.body)
: options.body;
if (body.sql) {
// Direct SQL query (query/exec endpoints)
queryDetails = `SQL: ${body.sql}`;
if (body.args && body.args.length > 0) {
queryDetails += ` | Args: [${body.args
.map((a: any) => (typeof a === "string" ? `"${a}"` : a))
.join(", ")}]`;
}
} else if (body.table) {
// Table-based query (find/find-one/select endpoints)
queryDetails = `Table: ${body.table}`;
if (body.criteria && Object.keys(body.criteria).length > 0) {
queryDetails += ` | Criteria: ${JSON.stringify(body.criteria)}`;
}
if (body.options) {
queryDetails += ` | Options: ${JSON.stringify(body.options)}`;
}
if (body.select) {
queryDetails += ` | Select: ${JSON.stringify(body.select)}`;
}
if (body.where) {
queryDetails += ` | Where: ${JSON.stringify(body.where)}`;
}
if (body.limit) {
queryDetails += ` | Limit: ${body.limit}`;
}
if (body.offset) {
queryDetails += ` | Offset: ${body.offset}`;
}
}
} catch (e) {
// Failed to parse body, ignore
}
}
try {
const result = await this.requestWithRetry(
url.toString(),
fetchOptions,
0,
startTime
);
const duration = performance.now() - startTime;
if (typeof console !== "undefined") {
const logMessage = `[HttpClient] ${method} ${path} completed in ${duration.toFixed(
2
)}ms`;
if (queryDetails && this.debug) {
console.log(logMessage);
console.log(`[HttpClient] ${queryDetails}`);
} else {
console.log(logMessage);
}
}
return result;
} catch (error) {
const duration = performance.now() - startTime;
if (typeof console !== "undefined") {
// For 404 errors on find-one calls, log at warn level (not error) since "not found" is expected
// Application layer handles these cases in try-catch blocks
const is404FindOne =
path === "/v1/rqlite/find-one" &&
error instanceof SDKError &&
error.httpStatus === 404;
if (is404FindOne) {
// Log as warning for visibility, but not as error since it's expected behavior
console.warn(
`[HttpClient] ${method} ${path} returned 404 after ${duration.toFixed(
2
)}ms (expected for optional lookups)`
);
} else {
const errorMessage = `[HttpClient] ${method} ${path} failed after ${duration.toFixed(
2
)}ms:`;
console.error(errorMessage, error);
if (queryDetails && this.debug) {
console.error(`[HttpClient] ${queryDetails}`);
}
}
}
// Normalize native errors (TypeError, AbortError) into a typed SDKError
// so the app gets a stable `.code`/`.httpStatus` instead of a bare
// platform "Network request failed" (bugboard #129).
const sdkError = this.normalizeError(error, requestTimeout);
// Call the network error callback if configured. This allows the app to
// trigger gateway failover.
if (this.onNetworkError) {
this.onNetworkError(sdkError, {
method,
path,
isRetry: false,
attempt: this.maxRetries, // All retries exhausted
});
}
throw sdkError;
} finally {
clearTimeout(timeoutId);
}
}
private async requestWithRetry(
url: string,
options: RequestInit,
attempt: number = 0,
startTime?: number // Track start time for timing across retries
): Promise<any> {
try {
const response = await this.fetch(url, options);
if (!response.ok) {
let body: any;
try {
body = await response.json();
} catch {
body = { error: response.statusText };
}
throw SDKError.fromResponse(response.status, body);
}
// Request succeeded - return response
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
return response.json();
}
return response.text();
} catch (error) {
const isRetryableError =
error instanceof SDKError &&
[408, 429, 500, 502, 503, 504].includes(error.httpStatus);
// Retry on same gateway for retryable HTTP errors
if (isRetryableError && attempt < this.maxRetries) {
if (typeof console !== "undefined") {
console.warn(
`[HttpClient] Retrying request (attempt ${attempt + 1}/${this.maxRetries})`
);
}
await new Promise((resolve) =>
setTimeout(resolve, this.retryDelayMs * (attempt + 1))
);
return this.requestWithRetry(url, options, attempt + 1, startTime);
}
// All retries exhausted - throw error for app to handle
throw error;
}
}
async get<T = any>(
path: string,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("GET", path, options);
}
async post<T = any>(
path: string,
body?: any,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("POST", path, { ...options, body });
}
async put<T = any>(
path: string,
body?: any,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("PUT", path, { ...options, body });
}
async delete<T = any>(
path: string,
options?: Omit<Parameters<typeof this.request>[2], "body">
): Promise<T> {
return this.request<T>("DELETE", path, options);
}
/**
* Upload a file using multipart/form-data
* This is a special method for file uploads that bypasses JSON serialization
*/
async uploadFile<T = any>(
path: string,
formData: FormData,
options?: {
timeout?: number;
}
): Promise<T> {
const startTime = performance.now(); // Track upload start time
const url = new URL(this.baseURL + path);
const headers: Record<string, string> = {
...this.getAuthHeaders(path),
// Don't set Content-Type - browser will set it with boundary
};
const controller = new AbortController();
const requestTimeout = options?.timeout ?? this.timeout * 5; // 5x timeout for uploads
const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
const fetchOptions: RequestInit = {
method: "POST",
headers,
body: formData,
signal: controller.signal,
};
try {
const result = await this.requestWithRetry(
url.toString(),
fetchOptions,
0,
startTime
);
const duration = performance.now() - startTime;
if (typeof console !== "undefined") {
console.log(
`[HttpClient] POST ${path} (upload) completed in ${duration.toFixed(
2
)}ms`
);
}
return result;
} catch (error) {
const duration = performance.now() - startTime;
if (typeof console !== "undefined") {
console.error(
`[HttpClient] POST ${path} (upload) failed after ${duration.toFixed(
2
)}ms:`,
error
);
}
// Call the network error callback if configured
if (this.onNetworkError) {
const sdkError =
error instanceof SDKError
? error
: new SDKError(
error instanceof Error ? error.message : String(error),
0,
"NETWORK_ERROR"
);
this.onNetworkError(sdkError, {
method: "POST",
path,
isRetry: false,
attempt: this.maxRetries,
});
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Get a binary response (returns Response object for streaming)
*/
async getBinary(path: string): Promise<Response> {
const url = new URL(this.baseURL + path);
const headers: Record<string, string> = {
...this.getAuthHeaders(path),
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout * 5); // 5x timeout for downloads
const fetchOptions: RequestInit = {
method: "GET",
headers,
signal: controller.signal,
};
try {
const response = await this.fetch(url.toString(), fetchOptions);
if (!response.ok) {
clearTimeout(timeoutId);
const errorBody = await response.json().catch(() => ({
error: response.statusText,
}));
throw SDKError.fromResponse(response.status, errorBody);
}
return response;
} catch (error) {
clearTimeout(timeoutId);
// Call the network error callback if configured
if (this.onNetworkError) {
const sdkError =
error instanceof SDKError
? error
: new SDKError(
error instanceof Error ? error.message : String(error),
0,
"NETWORK_ERROR"
);
this.onNetworkError(sdkError, {
method: "GET",
path,
isRetry: false,
attempt: 0,
});
}
throw error;
}
}
getToken(): string | undefined {
return this.getAuthToken();
}
}