Bump version to 0.4.1 and enhance HttpClient to support multiple base URLs and gateway health checks

This commit is contained in:
anonpenguin23 2026-01-13 14:58:05 +02:00
parent 0ed9e9a00e
commit dbe40c6f16
3 changed files with 162 additions and 15 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@debros/network-ts-sdk", "name": "@debros/network-ts-sdk",
"version": "0.4.0", "version": "0.4.1",
"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,11 +1,12 @@
import { SDKError } from "../errors"; import { SDKError } from "../errors";
export interface HttpClientConfig { export interface HttpClientConfig {
baseURL: string; baseURL: string | string[];
timeout?: number; timeout?: number;
maxRetries?: number; maxRetries?: number;
retryDelayMs?: number; retryDelayMs?: number;
fetch?: typeof fetch; fetch?: typeof fetch;
gatewayHealthCheckCooldownMs?: number;
} }
/** /**
@ -31,22 +32,38 @@ 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 baseURL: string; private baseURLs: 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.baseURL = config.baseURL.replace(/\/$/, ""); this.baseURLs = (Array.isArray(config.baseURL) ? config.baseURL : [config.baseURL])
this.timeout = config.timeout ?? 60000; // Increased from 30s to 60s for pub/sub operations .map(url => url.replace(/\/$/, ""));
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) {
@ -121,6 +138,92 @@ export class HttpClient {
return this.apiKey; return this.apiKey;
} }
/**
* Get the current base URL (for single gateway or current gateway in multi-gateway setup)
*/
private getCurrentBaseURL(): string {
return this.baseURLs[this.currentURLIndex];
}
/**
* 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>(
method: "GET" | "POST" | "PUT" | "DELETE", method: "GET" | "POST" | "PUT" | "DELETE",
path: string, path: string,
@ -132,7 +235,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.baseURL + path); const url = new URL(this.getCurrentBaseURL() + 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));
@ -261,8 +364,11 @@ 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);
@ -276,22 +382,61 @@ export class HttpClient {
throw SDKError.fromResponse(response.status, body); throw SDKError.fromResponse(response.status, body);
} }
// Request succeeded - return response
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) { if (contentType?.includes("application/json")) {
return response.json(); return response.json();
} }
return response.text(); return response.text();
} catch (error) { } catch (error) {
if ( const isRetryableError =
error instanceof SDKError && error instanceof SDKError &&
attempt < this.maxRetries && [408, 429, 500, 502, 503, 504].includes(error.httpStatus);
[408, 429, 500, 502, 503, 504].includes(error.httpStatus)
) { const isNetworkError =
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 (typeof console !== "undefined") {
console.warn(
`[HttpClient] Retrying request on same gateway (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); return this.requestWithRetry(url, options, attempt + 1, startTime, gatewayAttempt);
} }
// 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);
}
}
throw error; throw error;
} }
} }
@ -338,7 +483,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.baseURL + path); const url = new URL(this.getCurrentBaseURL() + 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
@ -391,7 +536,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.baseURL + path); const url = new URL(this.getCurrentBaseURL() + path);
const headers: Record<string, string> = { const headers: Record<string, string> = {
...this.getAuthHeaders(path), ...this.getAuthHeaders(path),
}; };

View File

@ -49,9 +49,11 @@ export function createClient(config: ClientConfig): Client {
}); });
// Derive WebSocket URL from baseURL if not explicitly provided // Derive WebSocket URL from baseURL if not explicitly provided
// If multiple base URLs are provided, use the first one for WebSocket (primary gateway)
const primaryBaseURL = Array.isArray(config.baseURL) ? config.baseURL[0] : config.baseURL;
const wsURL = const wsURL =
config.wsConfig?.wsURL ?? config.wsConfig?.wsURL ??
config.baseURL.replace(/^http/, "ws").replace(/\/$/, ""); 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, {