diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index e264723bd..85fda1d1a 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,13 +1,13 @@ +import packageJson from "@/../package.json"; import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { StatusCode } from "@ensnode/ensrainbow-sdk"; import { Hono } from "hono"; import type { Context as HonoContext } from "hono"; import { cors } from "hono/cors"; - -import packageJson from "@/../package.json"; -import { ENSRainbowDB, SCHEMA_VERSION } from "@/lib/database"; -import { ENSRainbowServer } from "@/lib/server"; -import { logger } from "@/utils/logger"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { logger } from "../utils/logger"; +import { ENSRainbowDB, SCHEMA_VERSION } from "./database"; +import { ENSRainbowServer } from "./server"; /** * Creates and configures an ENS Rainbow api @@ -32,7 +32,11 @@ export async function createApi(db: ENSRainbowDB): Promise { logger.debug(`Healing request for labelhash: ${labelhash}`); const result = await server.heal(labelhash); logger.debug(`Heal result:`, result); - return c.json(result, result.errorCode); + + // Map error codes > 1000 to 500, otherwise use the original code + const statusCode = result.errorCode && result.errorCode >= 1000 ? 500 : result.errorCode; + + return c.json(result, statusCode as ContentfulStatusCode); }); api.get("/health", (c: HonoContext) => { @@ -45,7 +49,11 @@ export async function createApi(db: ENSRainbowDB): Promise { logger.debug("Label count request"); const result = await server.labelCount(); logger.debug(`Count result:`, result); - return c.json(result, result.errorCode); + + // Map error codes > 1000 to 500, otherwise use the original code + const statusCode = result.errorCode && result.errorCode >= 1000 ? 500 : result.errorCode; + + return c.json(result, statusCode as ContentfulStatusCode); }); api.get("/v1/version", (c: HonoContext) => { diff --git a/packages/ensrainbow-sdk/src/client.test.ts b/packages/ensrainbow-sdk/src/client.test.ts index 0e27089e2..8d6ce157d 100644 --- a/packages/ensrainbow-sdk/src/client.test.ts +++ b/packages/ensrainbow-sdk/src/client.test.ts @@ -19,6 +19,7 @@ describe("EnsRainbowApiClient", () => { expect(client.getOptions()).toEqual({ endpointUrl: new URL(DEFAULT_ENSRAINBOW_URL), cacheCapacity: EnsRainbowApiClient.DEFAULT_CACHE_CAPACITY, + requestTimeout: EnsRainbowApiClient.DEFAULT_REQUEST_TIMEOUT, } satisfies EnsRainbowApiClientOptions); }); @@ -32,6 +33,7 @@ describe("EnsRainbowApiClient", () => { expect(client.getOptions()).toEqual({ endpointUrl: customEndpointUrl, cacheCapacity: 0, + requestTimeout: EnsRainbowApiClient.DEFAULT_REQUEST_TIMEOUT, } satisfies EnsRainbowApiClientOptions); }); diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 97f1cc237..27b2e2391 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -23,7 +23,8 @@ export namespace EnsRainbow { type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; export interface HealthResponse { - status: "ok"; + status: "ok" | "error"; + error?: string; } export interface BaseHealResponse { @@ -64,17 +65,48 @@ export namespace EnsRainbow { errorCode: typeof ErrorCode.BadRequest; } + export interface HealTimeoutError + extends BaseHealResponse { + status: typeof StatusCode.Error; + label?: never; + error: string; + errorCode: typeof ErrorCode.TIMEOUT; + } + + export interface HealNetworkOfflineError + extends BaseHealResponse { + status: typeof StatusCode.Error; + label?: never; + error: string; + errorCode: typeof ErrorCode.NETWORK_OFFLINE; + } + + export interface HealGeneralNetworkError + extends BaseHealResponse { + status: typeof StatusCode.Error; + label?: never; + error: string; + errorCode: typeof ErrorCode.GENERAL_NETWORK_ERROR; + } + export type HealResponse = | HealSuccess | HealNotFoundError | HealServerError - | HealBadRequestError; + | HealBadRequestError + | HealTimeoutError + | HealNetworkOfflineError + | HealGeneralNetworkError; + export type HealError = Exclude; /** * Server errors should not be cached. */ - export type CacheableHealResponse = Exclude; + export type CacheableHealResponse = Exclude< + HealResponse, + HealServerError | HealTimeoutError | HealNetworkOfflineError | HealGeneralNetworkError + >; export interface BaseCountResponse { status: Status; @@ -102,7 +134,39 @@ export namespace EnsRainbow { errorCode: typeof ErrorCode.ServerError; } - export type CountResponse = CountSuccess | CountServerError; + export interface CountNetworkError + extends BaseCountResponse { + status: typeof StatusCode.Error; + count?: never; + timestamp?: never; + error: string; + errorCode: typeof ErrorCode.GENERAL_NETWORK_ERROR; + } + + export interface CountTimeoutError + extends BaseCountResponse { + status: typeof StatusCode.Error; + count?: never; + timestamp?: never; + error: string; + errorCode: typeof ErrorCode.TIMEOUT; + } + + export interface CountNetworkOfflineError + extends BaseCountResponse { + status: typeof StatusCode.Error; + count?: never; + timestamp?: never; + error: string; + errorCode: typeof ErrorCode.NETWORK_OFFLINE; + } + + export type CountResponse = + | CountSuccess + | CountServerError + | CountNetworkError + | CountTimeoutError + | CountNetworkOfflineError; /** * ENSRainbow version information. @@ -140,6 +204,12 @@ export interface EnsRainbowApiClientOptions { * The URL of an ENSRainbow API endpoint. */ endpointUrl: URL; + + /** + * Default timeout for API requests in milliseconds. + * Defaults to 10000 (10 seconds). + */ + requestTimeout?: number; } /** @@ -160,6 +230,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { private readonly cache: Cache; public static readonly DEFAULT_CACHE_CAPACITY = 1000; + public static readonly DEFAULT_REQUEST_TIMEOUT = 10000; /** * Create default client options. @@ -170,6 +241,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { return { endpointUrl: new URL(DEFAULT_ENSRAINBOW_URL), cacheCapacity: EnsRainbowApiClient.DEFAULT_CACHE_CAPACITY, + requestTimeout: EnsRainbowApiClient.DEFAULT_REQUEST_TIMEOUT, }; } @@ -184,6 +256,35 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { ); } + /** + * Helper method to fetch data with timeout handling + * + * @param endpoint - The endpoint to fetch from + * @returns The response JSON data + * @throws Will throw an error if the fetch fails + */ + private async fetchWithTimeout(endpoint: string): Promise { + // Create abort controller for timeout handling + const controller = new AbortController(); + const timeout = this.options.requestTimeout || EnsRainbowApiClient.DEFAULT_REQUEST_TIMEOUT; + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(new URL(endpoint, this.options.endpointUrl), { + signal: controller.signal, + }); + + // Clear the timeout + clearTimeout(timeoutId); + + return response.json() as Promise; + } catch (error) { + // Clear the timeout if it hasn't fired yet + clearTimeout(timeoutId); + throw error; // Re-throw to be caught by the caller + } + } + /** * Attempt to heal a labelhash to its original label. * @@ -196,8 +297,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { * * @param labelhash all lowercase 64-digit hex string with 0x prefix (total length of 66 characters) * @returns a `HealResponse` indicating the result of the request and the healed label if successful - * @throws if the request fails due to network failures, DNS lookup failures, request timeouts, CORS violations, or Invalid URLs - * + * @example * ```typescript * const response = await client.heal( @@ -205,6 +305,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { * ); * * console.log(response); + * * // Output: * // { @@ -233,44 +334,157 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { return cachedResult; } - const response = await fetch(new URL(`/v1/heal/${labelhash}`, this.options.endpointUrl)); - const healResponse = (await response.json()) as EnsRainbow.HealResponse; + try { + const healResponse = await this.fetchWithTimeout( + `/v1/heal/${labelhash}`, + ); + + if (isCacheableHealResponse(healResponse)) { + this.cache.set(labelhash, healResponse); + } - if (isCacheableHealResponse(healResponse)) { - this.cache.set(labelhash, healResponse); + return healResponse; + } catch (error) { + // Handle network errors + return this.createNetworkErrorResponse(error); } + } - return healResponse; + /** + * Helper method to create appropriate network error responses + */ + private createNetworkErrorResponse(error: unknown): EnsRainbow.HealResponse { + let errorMessage = "Unknown network error occurred"; + + if (error instanceof Error) { + errorMessage = error.message; + + // DOMException is thrown for aborts, including timeouts + if (error.name === "AbortError") { + return { + status: StatusCode.Error, + error: "Request timed out", + errorCode: ErrorCode.TIMEOUT, + } as EnsRainbow.HealTimeoutError; + } + + // Network connectivity issues + if ( + errorMessage.toLowerCase().includes("network") || + errorMessage.toLowerCase().includes("failed to fetch") + ) { + return { + status: StatusCode.Error, + error: "Network connection lost or unavailable", + errorCode: ErrorCode.NETWORK_OFFLINE, + } as EnsRainbow.HealNetworkOfflineError; + } + } + + // Default to general network error + return { + status: StatusCode.Error, + error: errorMessage, + errorCode: ErrorCode.GENERAL_NETWORK_ERROR, + } as EnsRainbow.HealGeneralNetworkError; } /** * Get Count of Healable Labels * * @returns a `CountResponse` indicating the result and the timestamp of the request and the number of healable labels if successful - * @throws if the request fails due to network failures, DNS lookup failures, request timeouts, CORS violations, or Invalid URLs - * * @example * * const response = await client.count(); * * console.log(response); * + * // Success case: * // { * // "status": "success", * // "count": 133856894, * // "timestamp": "2024-01-30T11:18:56Z" * // } * + * // Server error case: + * // { + * // "status": "error", + * // "error": "Server error", + * // "errorCode": 500 + * // } + * + * // Network timeout error case: + * // { + * // "status": "error", + * // "error": "Connection timed out", + * // "errorCode": 1000 + * // } + * + * // Network offline error case: + * // { + * // "status": "error", + * // "error": "Server is unreachable", + * // "errorCode": 1001 + * // } + * + * // General network error case: + * // { + * // "status": "error", + * // "error": "Unknown network error", + * // "errorCode": 1099 + * // } */ async count(): Promise { - const response = await fetch(new URL("/v1/labels/count", this.options.endpointUrl)); + try { + return await this.fetchWithTimeout("/v1/labels/count"); + } catch (error) { + // Handle network errors + return this.createCountNetworkErrorResponse(error); + } + } - return response.json() as Promise; + /** + * Helper method to create appropriate network error responses for count + */ + private createCountNetworkErrorResponse(error: unknown): EnsRainbow.CountResponse { + let errorMessage = "Unknown network error occurred"; + + if (error instanceof Error) { + errorMessage = error.message; + + // DOMException is thrown for aborts, including timeouts + if (error.name === "AbortError") { + return { + status: StatusCode.Error, + error: "Request timed out", + errorCode: ErrorCode.TIMEOUT, + } as EnsRainbow.CountTimeoutError; + } + + // Network connectivity issues + if ( + errorMessage.toLowerCase().includes("network") || + errorMessage.toLowerCase().includes("failed to fetch") || + errorMessage.toLowerCase().includes("fetch failed") + ) { + return { + status: StatusCode.Error, + error: "Network connection lost or unavailable", + errorCode: ErrorCode.NETWORK_OFFLINE, + } as EnsRainbow.CountNetworkOfflineError; + } + } + + // Default to general network error + return { + status: StatusCode.Error, + error: errorMessage, + errorCode: ErrorCode.GENERAL_NETWORK_ERROR, + } as EnsRainbow.CountNetworkError; } /** - * - * Simple verification that the service is running, either in your local setup or for the provided hosted instance + * Simple verification that the service is running * * @returns a status of ENS Rainbow service * @example @@ -279,14 +493,27 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { * * console.log(response); * + * // Success case: * // { - * // "status": "ok", + * // "status": "ok" + * // } + * + * // Error case: + * // { + * // "status": "error", + * // "error": "Server is unreachable" * // } */ async health(): Promise { - const response = await fetch(new URL("/health", this.options.endpointUrl)); - - return response.json() as Promise; + try { + return await this.fetchWithTimeout("/health"); + } catch (error) { + // For health checks, we'll return a custom error status + return { + status: "error", + error: error instanceof Error ? error.message : "Unknown network error", + }; + } } /** @@ -324,6 +551,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { const deepCopy = { cacheCapacity: this.options.cacheCapacity, endpointUrl: new URL(this.options.endpointUrl.href), + requestTimeout: this.options.requestTimeout, } satisfies EnsRainbowApiClientOptions; return Object.freeze(deepCopy); @@ -353,5 +581,26 @@ export const isHealError = ( export const isCacheableHealResponse = ( response: EnsRainbow.HealResponse, ): response is EnsRainbow.CacheableHealResponse => { - return response.status === StatusCode.Success || response.errorCode !== ErrorCode.ServerError; + return ( + response.status === StatusCode.Success || + (response.status === StatusCode.Error && + response.errorCode !== ErrorCode.ServerError && + response.errorCode !== ErrorCode.TIMEOUT && + response.errorCode !== ErrorCode.NETWORK_OFFLINE && + response.errorCode !== ErrorCode.GENERAL_NETWORK_ERROR) + ); +}; + +/** + * Determines if a heal error is retryable (i.e., it's a network error) + * + * @param error - The heal error to check + * @returns true if the error is a network error (TIMEOUT, NETWORK_OFFLINE, or GENERAL_NETWORK_ERROR), false otherwise + */ +export const isRetryableHealError = (error: EnsRainbow.HealError): boolean => { + return ( + error.errorCode === ErrorCode.TIMEOUT || + error.errorCode === ErrorCode.NETWORK_OFFLINE || + error.errorCode === ErrorCode.GENERAL_NETWORK_ERROR + ); }; diff --git a/packages/ensrainbow-sdk/src/consts.ts b/packages/ensrainbow-sdk/src/consts.ts index 480d7753e..f6aa4c0e5 100644 --- a/packages/ensrainbow-sdk/src/consts.ts +++ b/packages/ensrainbow-sdk/src/consts.ts @@ -9,4 +9,8 @@ export const ErrorCode = { BadRequest: 400, NotFound: 404, ServerError: 500, + // Network error codes + TIMEOUT: 1000, + NETWORK_OFFLINE: 1001, + GENERAL_NETWORK_ERROR: 1099, } as const;