Refactor HttpClient and WSClient to simplify configuration and improve clarity; remove unused gateway health logic

This commit is contained in:
anonpenguin23 2026-01-13 15:32:19 +02:00
parent dbe40c6f16
commit 25303e7913
5 changed files with 33 additions and 157 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@debros/network-ts-sdk", "name": "@debros/network-ts-sdk",
"version": "0.4.1", "version": "0.4.2",
"description": "TypeScript SDK for DeBros Network Gateway", "description": "TypeScript SDK for DeBros Network Gateway",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@ -1,12 +1,11 @@
import { SDKError } from "../errors"; import { SDKError } from "../errors";
export interface HttpClientConfig { export interface HttpClientConfig {
baseURL: string | string[]; baseURL: string;
timeout?: number; timeout?: number;
maxRetries?: number; maxRetries?: number;
retryDelayMs?: number; retryDelayMs?: number;
fetch?: typeof fetch; fetch?: typeof fetch;
gatewayHealthCheckCooldownMs?: number;
} }
/** /**
@ -32,38 +31,22 @@ function createFetchWithTLSConfig(): typeof fetch {
return globalThis.fetch; return globalThis.fetch;
} }
interface GatewayHealth {
url: string;
unhealthyUntil: number | null; // Timestamp when gateway becomes healthy again
}
export class HttpClient { export class HttpClient {
private baseURLs: string[]; private baseURL: string;
private currentURLIndex: number = 0;
private timeout: number; private timeout: number;
private maxRetries: number; private maxRetries: number;
private retryDelayMs: number; private retryDelayMs: number;
private fetch: typeof fetch; private fetch: typeof fetch;
private apiKey?: string; private apiKey?: string;
private jwt?: string; private jwt?: string;
private gatewayHealthCheckCooldownMs: number;
private gatewayHealth: Map<string, GatewayHealth>;
constructor(config: HttpClientConfig) { constructor(config: HttpClientConfig) {
this.baseURLs = (Array.isArray(config.baseURL) ? config.baseURL : [config.baseURL]) this.baseURL = config.baseURL.replace(/\/$/, "");
.map(url => url.replace(/\/$/, ""));
this.timeout = config.timeout ?? 60000; this.timeout = config.timeout ?? 60000;
this.maxRetries = config.maxRetries ?? 3; this.maxRetries = config.maxRetries ?? 3;
this.retryDelayMs = config.retryDelayMs ?? 1000; this.retryDelayMs = config.retryDelayMs ?? 1000;
this.gatewayHealthCheckCooldownMs = config.gatewayHealthCheckCooldownMs ?? 600000; // Default 10 minutes
// Use provided fetch or create one with proper TLS configuration for staging certificates // Use provided fetch or create one with proper TLS configuration for staging certificates
this.fetch = config.fetch ?? createFetchWithTLSConfig(); this.fetch = config.fetch ?? createFetchWithTLSConfig();
// Initialize gateway health tracking
this.gatewayHealth = new Map();
this.baseURLs.forEach(url => {
this.gatewayHealth.set(url, { url, unhealthyUntil: null });
});
} }
setApiKey(apiKey?: string) { setApiKey(apiKey?: string) {
@ -139,89 +122,10 @@ export class HttpClient {
} }
/** /**
* Get the current base URL (for single gateway or current gateway in multi-gateway setup) * Get the base URL
*/ */
private getCurrentBaseURL(): string { getBaseURL(): string {
return this.baseURLs[this.currentURLIndex]; return this.baseURL;
}
/**
* Get all base URLs (for WebSocket or other purposes that need all gateways)
*/
getBaseURLs(): string[] {
return [...this.baseURLs];
}
/**
* Check if a gateway is healthy (not in cooldown period)
*/
private isGatewayHealthy(url: string): boolean {
const health = this.gatewayHealth.get(url);
if (!health || health.unhealthyUntil === null) {
return true;
}
const now = Date.now();
if (now >= health.unhealthyUntil) {
// Cooldown period expired, mark as healthy again
health.unhealthyUntil = null;
return true;
}
return false;
}
/**
* Mark a gateway as unhealthy for the cooldown period
*/
private markGatewayUnhealthy(url: string): void {
const health = this.gatewayHealth.get(url);
if (health) {
health.unhealthyUntil = Date.now() + this.gatewayHealthCheckCooldownMs;
if (typeof console !== "undefined") {
console.warn(
`[HttpClient] Gateway marked unhealthy for ${this.gatewayHealthCheckCooldownMs / 1000}s:`,
url
);
}
}
}
/**
* Try the next healthy gateway in the list
* Returns the index of the next healthy gateway, or -1 if none available
*/
private findNextHealthyGateway(): number {
if (this.baseURLs.length <= 1) {
return -1; // No other gateways to try
}
const startIndex = this.currentURLIndex;
let attempts = 0;
// Try each gateway once (excluding current)
while (attempts < this.baseURLs.length - 1) {
const nextIndex = (startIndex + attempts + 1) % this.baseURLs.length;
const nextUrl = this.baseURLs[nextIndex];
if (this.isGatewayHealthy(nextUrl)) {
return nextIndex;
}
attempts++;
}
return -1; // No healthy gateways found
}
/**
* Move to the next healthy gateway
*/
private moveToNextGateway(): boolean {
const nextIndex = this.findNextHealthyGateway();
if (nextIndex === -1) {
return false;
}
this.currentURLIndex = nextIndex;
return true;
} }
async request<T = any>( async request<T = any>(
@ -235,7 +139,7 @@ export class HttpClient {
} = {} } = {}
): Promise<T> { ): Promise<T> {
const startTime = performance.now(); // Track request start time const startTime = performance.now(); // Track request start time
const url = new URL(this.getCurrentBaseURL() + path); const url = new URL(this.baseURL + path);
if (options.query) { if (options.query) {
Object.entries(options.query).forEach(([key, value]) => { Object.entries(options.query).forEach(([key, value]) => {
url.searchParams.append(key, String(value)); url.searchParams.append(key, String(value));
@ -364,11 +268,8 @@ export class HttpClient {
url: string, url: string,
options: RequestInit, options: RequestInit,
attempt: number = 0, attempt: number = 0,
startTime?: number, // Track start time for timing across retries startTime?: number // Track start time for timing across retries
gatewayAttempt: number = 0 // Track gateway failover attempts
): Promise<any> { ): Promise<any> {
const currentGatewayUrl = this.getCurrentBaseURL();
try { try {
const response = await this.fetch(url, options); const response = await this.fetch(url, options);
@ -393,50 +294,20 @@ export class HttpClient {
error instanceof SDKError && error instanceof SDKError &&
[408, 429, 500, 502, 503, 504].includes(error.httpStatus); [408, 429, 500, 502, 503, 504].includes(error.httpStatus);
const isNetworkError = // Retry on same gateway for retryable HTTP errors
error instanceof TypeError ||
(error instanceof Error && error.message.includes('fetch'));
// Retry on the same gateway first (for retryable HTTP errors)
if (isRetryableError && attempt < this.maxRetries) { if (isRetryableError && attempt < this.maxRetries) {
if (typeof console !== "undefined") { if (typeof console !== "undefined") {
console.warn( console.warn(
`[HttpClient] Retrying request on same gateway (attempt ${attempt + 1}/${this.maxRetries}):`, `[HttpClient] Retrying request (attempt ${attempt + 1}/${this.maxRetries})`
currentGatewayUrl
); );
} }
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout(resolve, this.retryDelayMs * (attempt + 1)) setTimeout(resolve, this.retryDelayMs * (attempt + 1))
); );
return this.requestWithRetry(url, options, attempt + 1, startTime, gatewayAttempt); return this.requestWithRetry(url, options, attempt + 1, startTime);
}
// If all retries on current gateway failed, mark it unhealthy and try next gateway
if ((isNetworkError || isRetryableError) && gatewayAttempt < this.baseURLs.length - 1) {
// Mark current gateway as unhealthy
this.markGatewayUnhealthy(currentGatewayUrl);
// Try to move to next healthy gateway
if (this.moveToNextGateway()) {
if (typeof console !== "undefined") {
console.warn(
`[HttpClient] Gateway exhausted retries, trying next gateway (${gatewayAttempt + 1}/${this.baseURLs.length - 1}):`,
this.getCurrentBaseURL()
);
}
// Update URL to use the new gateway
const currentPath = url.substring(url.indexOf('/', 8)); // Get path after protocol://host
const newUrl = this.getCurrentBaseURL() + currentPath;
// Small delay before trying next gateway
await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs));
// Reset attempt counter for new gateway
return this.requestWithRetry(newUrl, options, 0, startTime, gatewayAttempt + 1);
}
} }
// All retries exhausted - throw error for app to handle
throw error; throw error;
} }
} }
@ -483,7 +354,7 @@ export class HttpClient {
} }
): Promise<T> { ): Promise<T> {
const startTime = performance.now(); // Track upload start time const startTime = performance.now(); // Track upload start time
const url = new URL(this.getCurrentBaseURL() + path); const url = new URL(this.baseURL + path);
const headers: Record<string, string> = { const headers: Record<string, string> = {
...this.getAuthHeaders(path), ...this.getAuthHeaders(path),
// Don't set Content-Type - browser will set it with boundary // Don't set Content-Type - browser will set it with boundary
@ -536,7 +407,7 @@ export class HttpClient {
* Get a binary response (returns Response object for streaming) * Get a binary response (returns Response object for streaming)
*/ */
async getBinary(path: string): Promise<Response> { async getBinary(path: string): Promise<Response> {
const url = new URL(this.getCurrentBaseURL() + path); const url = new URL(this.baseURL + path);
const headers: Record<string, string> = { const headers: Record<string, string> = {
...this.getAuthHeaders(path), ...this.getAuthHeaders(path),
}; };

View File

@ -15,10 +15,11 @@ export type WSOpenHandler = () => void;
/** /**
* Simple WebSocket client with minimal abstractions * Simple WebSocket client with minimal abstractions
* No complex reconnection, no heartbeats - keep it simple * No complex reconnection, no failover - keep it simple
* Gateway failover is handled at the application layer
*/ */
export class WSClient { export class WSClient {
private url: string; private wsURL: string;
private timeout: number; private timeout: number;
private authToken?: string; private authToken?: string;
private WebSocketClass: typeof WebSocket; private WebSocketClass: typeof WebSocket;
@ -31,12 +32,19 @@ export class WSClient {
private isClosed = false; private isClosed = false;
constructor(config: WSClientConfig) { constructor(config: WSClientConfig) {
this.url = config.wsURL; this.wsURL = config.wsURL;
this.timeout = config.timeout ?? 30000; this.timeout = config.timeout ?? 30000;
this.authToken = config.authToken; this.authToken = config.authToken;
this.WebSocketClass = config.WebSocket ?? WebSocket; this.WebSocketClass = config.WebSocket ?? WebSocket;
} }
/**
* Get the current WebSocket URL
*/
get url(): string {
return this.wsURL;
}
/** /**
* Connect to WebSocket server * Connect to WebSocket server
*/ */
@ -56,7 +64,7 @@ export class WSClient {
this.ws.addEventListener("open", () => { this.ws.addEventListener("open", () => {
clearTimeout(timeout); clearTimeout(timeout);
console.log("[WSClient] Connected to", this.url); console.log("[WSClient] Connected to", this.wsURL);
this.openHandlers.forEach((handler) => handler()); this.openHandlers.forEach((handler) => handler());
resolve(); resolve();
}); });
@ -71,6 +79,7 @@ export class WSClient {
clearTimeout(timeout); clearTimeout(timeout);
const error = new SDKError("WebSocket error", 500, "WS_ERROR", event); const error = new SDKError("WebSocket error", 500, "WS_ERROR", event);
this.errorHandlers.forEach((handler) => handler(error)); this.errorHandlers.forEach((handler) => handler(error));
reject(error);
}); });
this.ws.addEventListener("close", () => { this.ws.addEventListener("close", () => {
@ -88,7 +97,7 @@ export class WSClient {
* Build WebSocket URL with auth token * Build WebSocket URL with auth token
*/ */
private buildWSUrl(): string { private buildWSUrl(): string {
let url = this.url; let url = this.wsURL;
if (this.authToken) { if (this.authToken) {
const separator = url.includes("?") ? "&" : "?"; const separator = url.includes("?") ? "&" : "?";

View File

@ -17,7 +17,7 @@ export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
apiKey?: string; apiKey?: string;
jwt?: string; jwt?: string;
storage?: StorageAdapter; storage?: StorageAdapter;
wsConfig?: Partial<WSClientConfig>; wsConfig?: Partial<Omit<WSClientConfig, "wsURL">>;
functionsConfig?: FunctionsClientConfig; functionsConfig?: FunctionsClientConfig;
fetch?: typeof fetch; fetch?: typeof fetch;
} }
@ -48,12 +48,8 @@ export function createClient(config: ClientConfig): Client {
jwt: config.jwt, jwt: config.jwt,
}); });
// Derive WebSocket URL from baseURL if not explicitly provided // Derive WebSocket URL from baseURL
// If multiple base URLs are provided, use the first one for WebSocket (primary gateway) const wsURL = config.baseURL.replace(/^http/, "ws").replace(/\/$/, "");
const primaryBaseURL = Array.isArray(config.baseURL) ? config.baseURL[0] : config.baseURL;
const wsURL =
config.wsConfig?.wsURL ??
primaryBaseURL.replace(/^http/, "ws").replace(/\/$/, "");
const db = new DBClient(httpClient); const db = new DBClient(httpClient);
const pubsub = new PubSubClient(httpClient, { const pubsub = new PubSubClient(httpClient, {

View File

@ -55,7 +55,7 @@ function base64Decode(b64: string): string {
/** /**
* Simple PubSub client - one WebSocket connection per topic * Simple PubSub client - one WebSocket connection per topic
* No connection pooling, no reference counting - keep it simple * Gateway failover is handled at the application layer
*/ */
export class PubSubClient { export class PubSubClient {
private httpClient: HttpClient; private httpClient: HttpClient;