From a515ab8819297d22ea5056ded7771c37c0d52725 Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 6 Mar 2025 12:36:26 +0100 Subject: [PATCH 1/4] add tests for netowrk errors --- packages/ensrainbow-sdk/src/client.spec.ts | 42 +++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/ensrainbow-sdk/src/client.spec.ts b/packages/ensrainbow-sdk/src/client.spec.ts index 4c841c702..2210424a5 100644 --- a/packages/ensrainbow-sdk/src/client.spec.ts +++ b/packages/ensrainbow-sdk/src/client.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { type EnsRainbow, EnsRainbowApiClient, @@ -13,6 +13,8 @@ describe("EnsRainbowApiClient", () => { beforeEach(() => { client = new EnsRainbowApiClient(); + // Reset any mocks between tests + vi.restoreAllMocks(); }); it("should apply default options when no options provided", () => { @@ -84,6 +86,44 @@ describe("EnsRainbowApiClient", () => { status: "ok", } satisfies EnsRainbow.HealthResponse); }); + + describe("Network exceptions", () => { + it("should let connection lost exceptions flow through in heal method", async () => { + // Mock fetch to simulate a connection lost error + global.fetch = vi.fn().mockRejectedValue(new Error("Connection lost")); + + // The heal method should not catch the error + await expect(client.heal("0x1234567890abcdef")).rejects.toThrow("Connection lost"); + }); + + it("should let connection lost exceptions flow through in count method", async () => { + // Mock fetch to simulate a connection lost error + global.fetch = vi.fn().mockRejectedValue(new Error("Connection lost")); + + // The count method should not catch the error + await expect(client.count()).rejects.toThrow("Connection lost"); + }); + + it("should let connection lost exceptions flow through in health method", async () => { + // Mock fetch to simulate a connection lost error + global.fetch = vi.fn().mockRejectedValue(new Error("Connection lost")); + + // The health method should not catch the error + await expect(client.health()).rejects.toThrow("Connection lost"); + }); + }); + + describe("Real network exceptions (no mocking)", () => { + it("should let network errors flow through when connecting to non-existent endpoint", async () => { + // Create a client with a non-existent endpoint + const nonExistentClient = new EnsRainbowApiClient({ + endpointUrl: new URL("http://non-existent-domain-that-will-fail.example"), + }); + + // The API call should fail with a network error and not be caught by the client + await expect(nonExistentClient.health()).rejects.toThrow(); + }); + }); }); describe("HealResponse error detection", () => { From 29789e6532c74e3ce0b505ce288e96facc0e88a9 Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 6 Mar 2025 14:36:51 +0100 Subject: [PATCH 2/4] feat(ensrainbow-sdk): Enhance network error handling and resilience --- packages/ensrainbow-sdk/src/client.spec.ts | 267 ++++++++++++++++++- packages/ensrainbow-sdk/src/client.ts | 293 +++++++++++++++++++-- packages/ensrainbow-sdk/src/consts.ts | 4 + 3 files changed, 529 insertions(+), 35 deletions(-) diff --git a/packages/ensrainbow-sdk/src/client.spec.ts b/packages/ensrainbow-sdk/src/client.spec.ts index 2210424a5..288d1ad4c 100644 --- a/packages/ensrainbow-sdk/src/client.spec.ts +++ b/packages/ensrainbow-sdk/src/client.spec.ts @@ -5,6 +5,7 @@ import { EnsRainbowApiClientOptions, isCacheableHealResponse, isHealError, + isRetryableHealError, } from "./client"; import { DEFAULT_ENSRAINBOW_URL, ErrorCode, StatusCode } from "./consts"; @@ -21,6 +22,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); }); @@ -29,11 +31,13 @@ describe("EnsRainbowApiClient", () => { client = new EnsRainbowApiClient({ endpointUrl: customEndpointUrl, cacheCapacity: 0, + requestTimeout: 5000, }); expect(client.getOptions()).toEqual({ endpointUrl: customEndpointUrl, cacheCapacity: 0, + requestTimeout: 5000, } satisfies EnsRainbowApiClientOptions); }); @@ -87,41 +91,156 @@ describe("EnsRainbowApiClient", () => { } satisfies EnsRainbow.HealthResponse); }); - describe("Network exceptions", () => { - it("should let connection lost exceptions flow through in heal method", async () => { + describe("Network error handling", () => { + it("should handle timeout errors in heal method", async () => { + // Mock fetch to simulate a timeout error + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + global.fetch = vi.fn().mockRejectedValue(abortError); + + const response = await client.heal( + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + ); + + expect(response).toEqual({ + status: StatusCode.Error, + error: "Request timed out", + errorCode: ErrorCode.TIMEOUT, + } satisfies EnsRainbow.HealTimeoutError); + }); + + it("should handle network offline errors in heal method", async () => { + // Mock fetch to simulate a network offline error + global.fetch = vi.fn().mockRejectedValue(new Error("Failed to fetch")); + + const response = await client.heal( + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + ); + + expect(response).toEqual({ + status: StatusCode.Error, + error: "Network connection lost or unavailable", + errorCode: ErrorCode.NETWORK_OFFLINE, + } satisfies EnsRainbow.HealNetworkOfflineError); + }); + + it("should handle general network errors in heal method", async () => { + // Mock fetch to simulate a general network error + global.fetch = vi.fn().mockRejectedValue(new Error("Some other error")); + + const response = await client.heal( + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + ); + + expect(response).toEqual({ + status: StatusCode.Error, + error: "Some other error", + errorCode: ErrorCode.GENERAL_NETWORK_ERROR, + } satisfies EnsRainbow.HealGeneralNetworkError); + }); + + it("should handle timeout errors in count method", async () => { + // Mock fetch to simulate a timeout error + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + global.fetch = vi.fn().mockRejectedValue(abortError); + + const response = await client.count(); + + expect(response).toEqual({ + status: StatusCode.Error, + error: "Request timed out", + errorCode: ErrorCode.TIMEOUT, + } satisfies EnsRainbow.CountTimeoutError); + }); + + it("should handle network offline errors in count method", async () => { + // Mock fetch to simulate a network offline error + global.fetch = vi.fn().mockRejectedValue(new Error("Failed to fetch")); + + const response = await client.count(); + + expect(response).toEqual({ + status: StatusCode.Error, + error: "Network connection lost or unavailable", + errorCode: ErrorCode.NETWORK_OFFLINE, + } satisfies EnsRainbow.CountNetworkOfflineError); + }); + + it("should handle general network errors in count method", async () => { + // Mock fetch to simulate a general network error + global.fetch = vi.fn().mockRejectedValue(new Error("Some other error")); + + const response = await client.count(); + + expect(response).toEqual({ + status: StatusCode.Error, + error: "Some other error", + errorCode: ErrorCode.GENERAL_NETWORK_ERROR, + } satisfies EnsRainbow.CountNetworkError); + }); + + it("should handle network errors in health method", async () => { + // Mock fetch to simulate a network error + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + const response = await client.health(); + + expect(response).toEqual({ + status: "error", + error: "Network error", + } satisfies EnsRainbow.HealthResponse); + }); + }); + + describe("Deprecated Network exceptions tests", () => { + it("should handle connection lost exceptions in heal method", async () => { // Mock fetch to simulate a connection lost error global.fetch = vi.fn().mockRejectedValue(new Error("Connection lost")); - // The heal method should not catch the error - await expect(client.heal("0x1234567890abcdef")).rejects.toThrow("Connection lost"); + // The heal method should return a network error response + const response = await client.heal( + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + ); + + expect(response.status).toEqual(StatusCode.Error); + expect(response.errorCode).toEqual(ErrorCode.GENERAL_NETWORK_ERROR); }); - it("should let connection lost exceptions flow through in count method", async () => { + it("should handle connection lost exceptions in count method", async () => { // Mock fetch to simulate a connection lost error global.fetch = vi.fn().mockRejectedValue(new Error("Connection lost")); - // The count method should not catch the error - await expect(client.count()).rejects.toThrow("Connection lost"); + // The count method should return a network error response + const response = await client.count(); + + expect(response.status).toEqual(StatusCode.Error); + expect(response.errorCode).toEqual(ErrorCode.GENERAL_NETWORK_ERROR); }); - it("should let connection lost exceptions flow through in health method", async () => { + it("should handle connection lost exceptions in health method", async () => { // Mock fetch to simulate a connection lost error global.fetch = vi.fn().mockRejectedValue(new Error("Connection lost")); - // The health method should not catch the error - await expect(client.health()).rejects.toThrow("Connection lost"); + // The health method should return an error response + const response = await client.health(); + + expect(response.status).toEqual("error"); + expect(response.error).toEqual("Connection lost"); }); }); describe("Real network exceptions (no mocking)", () => { - it("should let network errors flow through when connecting to non-existent endpoint", async () => { + it("should handle network errors when connecting to non-existent endpoint", async () => { // Create a client with a non-existent endpoint const nonExistentClient = new EnsRainbowApiClient({ endpointUrl: new URL("http://non-existent-domain-that-will-fail.example"), }); - // The API call should fail with a network error and not be caught by the client - await expect(nonExistentClient.health()).rejects.toThrow(); + // The API call should return a network error response + const response = await nonExistentClient.health(); + expect(response.status).toEqual("error"); + expect(response.error).toBeTruthy(); }); }); }); @@ -165,6 +284,36 @@ describe("HealResponse error detection", () => { expect(isHealError(response)).toBe(true); }); + + it("should consider HealTimeoutError responses to be errors", async () => { + const response: EnsRainbow.HealTimeoutError = { + status: StatusCode.Error, + error: "Request timed out", + errorCode: ErrorCode.TIMEOUT, + }; + + expect(isHealError(response)).toBe(true); + }); + + it("should consider HealNetworkOfflineError responses to be errors", async () => { + const response: EnsRainbow.HealNetworkOfflineError = { + status: StatusCode.Error, + error: "Network connection lost or unavailable", + errorCode: ErrorCode.NETWORK_OFFLINE, + }; + + expect(isHealError(response)).toBe(true); + }); + + it("should consider HealGeneralNetworkError responses to be errors", async () => { + const response: EnsRainbow.HealGeneralNetworkError = { + status: StatusCode.Error, + error: "Some network error", + errorCode: ErrorCode.GENERAL_NETWORK_ERROR, + }; + + expect(isHealError(response)).toBe(true); + }); }); describe("HealResponse cacheability", () => { @@ -206,4 +355,96 @@ describe("HealResponse cacheability", () => { expect(isCacheableHealResponse(response)).toBe(false); }); + + it("should consider HealTimeoutError responses not cacheable", async () => { + const response: EnsRainbow.HealTimeoutError = { + status: StatusCode.Error, + error: "Request timed out", + errorCode: ErrorCode.TIMEOUT, + }; + + expect(isCacheableHealResponse(response)).toBe(false); + }); + + it("should consider HealNetworkOfflineError responses not cacheable", async () => { + const response: EnsRainbow.HealNetworkOfflineError = { + status: StatusCode.Error, + error: "Network connection lost or unavailable", + errorCode: ErrorCode.NETWORK_OFFLINE, + }; + + expect(isCacheableHealResponse(response)).toBe(false); + }); + + it("should consider HealGeneralNetworkError responses not cacheable", async () => { + const response: EnsRainbow.HealGeneralNetworkError = { + status: StatusCode.Error, + error: "Some network error", + errorCode: ErrorCode.GENERAL_NETWORK_ERROR, + }; + + expect(isCacheableHealResponse(response)).toBe(false); + }); +}); + +describe("RetryableHealError detection", () => { + it("should consider HealTimeoutError responses retryable", async () => { + const response: EnsRainbow.HealTimeoutError = { + status: StatusCode.Error, + error: "Request timed out", + errorCode: ErrorCode.TIMEOUT, + }; + + expect(isRetryableHealError(response)).toBe(true); + }); + + it("should consider HealNetworkOfflineError responses retryable", async () => { + const response: EnsRainbow.HealNetworkOfflineError = { + status: StatusCode.Error, + error: "Network connection lost or unavailable", + errorCode: ErrorCode.NETWORK_OFFLINE, + }; + + expect(isRetryableHealError(response)).toBe(true); + }); + + it("should consider HealGeneralNetworkError responses retryable", async () => { + const response: EnsRainbow.HealGeneralNetworkError = { + status: StatusCode.Error, + error: "Some network error", + errorCode: ErrorCode.GENERAL_NETWORK_ERROR, + }; + + expect(isRetryableHealError(response)).toBe(true); + }); + + it("should not consider HealNotFoundError responses retryable", async () => { + const response: EnsRainbow.HealNotFoundError = { + status: StatusCode.Error, + error: "Not found", + errorCode: ErrorCode.NotFound, + }; + + expect(isRetryableHealError(response)).toBe(false); + }); + + it("should not consider HealBadRequestError responses retryable", async () => { + const response: EnsRainbow.HealBadRequestError = { + status: StatusCode.Error, + error: "Bad request", + errorCode: ErrorCode.BadRequest, + }; + + expect(isRetryableHealError(response)).toBe(false); + }); + + it("should not consider HealServerError responses retryable", async () => { + const response: EnsRainbow.HealServerError = { + status: StatusCode.Error, + error: "Server error", + errorCode: ErrorCode.ServerError, + }; + + expect(isRetryableHealError(response)).toBe(false); + }); }); diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 1baeef165..7047543f3 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -21,7 +21,8 @@ export namespace EnsRainbow { type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; export interface HealthResponse { - status: "ok"; + status: "ok" | "error"; + error?: string; } export interface BaseHealResponse { @@ -62,17 +63,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; @@ -100,7 +132,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; } export interface EnsRainbowApiClientOptions { @@ -115,6 +179,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; } /** @@ -135,6 +205,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. @@ -145,6 +216,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { return { endpointUrl: new URL(DEFAULT_ENSRAINBOW_URL), cacheCapacity: EnsRainbowApiClient.DEFAULT_CACHE_CAPACITY, + requestTimeout: EnsRainbowApiClient.DEFAULT_REQUEST_TIMEOUT, }; } @@ -159,6 +231,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. * @@ -171,8 +272,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( @@ -180,6 +280,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { * ); * * console.log(response); + * * // Output: * // { @@ -208,44 +309,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); + } + } + + /** + * 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; + } + } - return response.json() as Promise; + // 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 @@ -254,14 +468,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", + }; + } } /** @@ -274,6 +501,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); @@ -303,5 +531,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; From 095bf0442cbfef2f4309681f557059b42da5dbd0 Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 6 Mar 2025 15:21:21 +0100 Subject: [PATCH 3/4] Map error codes >= 1000 to HTTP 500 status --- apps/ensrainbow/src/lib/api.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index 51cd113bf..3ed026fb4 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -2,6 +2,7 @@ import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { Hono } from "hono"; import type { Context as HonoContext } from "hono"; import { cors } from "hono/cors"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; import { logger } from "../utils/logger"; import { ENSRainbowDB } from "./database"; import { ENSRainbowServer } from "./server"; @@ -29,7 +30,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) => { @@ -42,7 +47,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); }); return api; From a59d40acdc126613bee027209a3392a48e78fc80 Mon Sep 17 00:00:00 2001 From: djstrong Date: Sat, 5 Apr 2025 16:43:33 +0200 Subject: [PATCH 4/4] merge main --- apps/ensrainbow/src/lib/api.ts | 2 +- packages/ensrainbow-sdk/src/client.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ensrainbow/src/lib/api.ts b/apps/ensrainbow/src/lib/api.ts index b359d853f..85fda1d1a 100644 --- a/apps/ensrainbow/src/lib/api.ts +++ b/apps/ensrainbow/src/lib/api.ts @@ -1,3 +1,4 @@ +import packageJson from "@/../package.json"; import type { EnsRainbow } from "@ensnode/ensrainbow-sdk"; import { StatusCode } from "@ensnode/ensrainbow-sdk"; import { Hono } from "hono"; @@ -5,7 +6,6 @@ import type { Context as HonoContext } from "hono"; import { cors } from "hono/cors"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import { logger } from "../utils/logger"; -import packageJson from "@/../package.json"; import { ENSRainbowDB, SCHEMA_VERSION } from "./database"; import { ENSRainbowServer } from "./server"; 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); });