From 67fa3b1288b88bc858d9d0f78b567f6845d99747 Mon Sep 17 00:00:00 2001 From: addnad Date: Thu, 30 Jul 2026 01:42:53 +0100 Subject: [PATCH] feat(subgraph): add configurable retry with exponential backoff to SubgraphClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transient failures (network errors, HTTP 5xx, invalid response bodies) are now retried automatically — 3 retries by default with exponential backoff (500ms, 1s, 2s). HTTP 4xx and GraphQL-level errors are never retried. Configurable via new SubgraphClientOptions.retries / retryDelay; retries: 0 opts out entirely. Also repairs two pre-existing JSON syntax errors in package.json (missing brace after the ./zk export, stray entries in peerDependenciesMeta) that made npm unable to parse the manifest — required to run any npm script. Closes #149 --- package.json | 6 +- src/subgraph/Client.ts | 75 +++++++++++---- src/subgraph/types.ts | 14 +++ tests/subgraph/ClientRetry.test.ts | 148 +++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 21 deletions(-) create mode 100644 tests/subgraph/ClientRetry.test.ts diff --git a/package.json b/package.json index ac8f634b..d2757de0 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "types": "./dist/esm/zk/index.d.ts", "import": "./dist/esm/zk/index.js", "require": "./dist/cjs/zk/index.js" + }, "./wallet": { "types": "./dist/esm/wallet/index.d.ts", "import": "./dist/esm/wallet/index.js", @@ -87,7 +88,8 @@ "@vitest/coverage-v8": "^1.6.1", "typedoc": "^0.28.20", "typescript": "^5.4.0", - "vitest": "^1.3.1" + "vitest": "^1.3.1", + "ws": "^8.16.0" }, "peerDependencies": { "snarkjs": ">=0.7.0" @@ -96,7 +98,5 @@ "snarkjs": { "optional": true } - "vitest": "^1.3.1", - "ws": "^8.16.0" } } diff --git a/src/subgraph/Client.ts b/src/subgraph/Client.ts index 0a9fcc90..55d801d2 100644 --- a/src/subgraph/Client.ts +++ b/src/subgraph/Client.ts @@ -19,14 +19,22 @@ import type { export const DEFAULT_SUBGRAPH_URL = "https://api.thegraph.com/subgraphs/name/whitechain/mainnet"; +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export class SubgraphClient { public readonly url: string; public readonly syncWarningThreshold: number; + public readonly retries: number; + public readonly retryDelay: number; private readonly fetchFn: typeof fetch; constructor(options: SubgraphClientOptions = {}) { this.url = options.url || DEFAULT_SUBGRAPH_URL; this.syncWarningThreshold = options.syncWarningThreshold ?? 50; + this.retries = Math.max(0, options.retries ?? 3); + this.retryDelay = Math.max(0, options.retryDelay ?? 500); this.fetchFn = options.fetchFn || globalThis.fetch; if (!this.fetchFn) { @@ -38,28 +46,62 @@ export class SubgraphClient { /** * Executes a raw GraphQL query against the configured Subgraph endpoint. + * + * Transient failures (network errors and HTTP 5xx responses) are retried + * automatically up to `retries` times with exponential backoff + * (retryDelay, retryDelay * 2, retryDelay * 4, ...). Client errors (HTTP 4xx) + * and GraphQL-level errors are never retried. */ async rawQuery>( query: string, variables?: TVariables ): Promise { - try { - const response = await this.fetchFn(this.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ query, variables }), - }); + const maxAttempts = this.retries + 1; + let lastErrorMessage = `Failed to fetch from Subgraph (${this.url}).`; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (attempt > 0) { + await sleep(this.retryDelay * 2 ** (attempt - 1)); + } + const isLastAttempt = attempt === maxAttempts - 1; + + let response: Response; + try { + response = await this.fetchFn(this.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ query, variables }), + }); + } catch (err: any) { + // Network failure or timeout — transient, eligible for retry. + lastErrorMessage = `Failed to fetch from Subgraph (${this.url}): ${err.message}`; + if (isLastAttempt) break; + continue; + } if (!response.ok) { - throw new WhiteChainError( - `Subgraph request failed with HTTP status ${response.status}: ${response.statusText}` - ); + const message = `Subgraph request failed with HTTP status ${response.status}: ${response.statusText}`; + if (response.status >= 500 && !isLastAttempt) { + // Gateway/server errors are transient — retry with backoff. + lastErrorMessage = message; + continue; + } + // 4xx responses are permanent client errors and are never retried. + throw new WhiteChainError(message); } - const json = await response.json(); + let json: any; + try { + json = await response.json(); + } catch (err: any) { + // Truncated/invalid body from a flaky gateway — treat as transient. + lastErrorMessage = `Failed to fetch from Subgraph (${this.url}): ${err.message}`; + if (isLastAttempt) break; + continue; + } if (json.errors && json.errors.length > 0) { const errorMessages = json.errors.map((e: any) => e.message).join("; "); @@ -67,12 +109,9 @@ export class SubgraphClient { } return json.data as TData; - } catch (err: any) { - if (err instanceof WhiteChainError) { - throw err; - } - throw new WhiteChainError(`Failed to fetch from Subgraph (${this.url}): ${err.message}`); } + + throw new WhiteChainError(lastErrorMessage); } /** diff --git a/src/subgraph/types.ts b/src/subgraph/types.ts index 1a29bea5..80484d6a 100644 --- a/src/subgraph/types.ts +++ b/src/subgraph/types.ts @@ -15,6 +15,20 @@ export interface SubgraphClientOptions { * Custom fetch implementation for Node or browser environments. */ fetchFn?: typeof fetch; + + /** + * Maximum number of automatic retries for transient failures (network errors + * and HTTP 5xx responses). Set to 0 to disable retrying entirely. + * @default 3 + */ + retries?: number; + + /** + * Base delay in milliseconds before the first retry. Subsequent retries wait + * exponentially longer (retryDelay, retryDelay * 2, retryDelay * 4, ...). + * @default 500 + */ + retryDelay?: number; } export interface Trader { diff --git a/tests/subgraph/ClientRetry.test.ts b/tests/subgraph/ClientRetry.test.ts new file mode 100644 index 00000000..793a1d6a --- /dev/null +++ b/tests/subgraph/ClientRetry.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { SubgraphClient } from "../../src/subgraph/Client.js"; +import { WhiteChainError } from "../../src/types.js"; + +const okResponse = (data: any) => ({ + ok: true, + json: async () => ({ data }), +}); + +const errorResponse = (status: number, statusText: string) => ({ + ok: false, + status, + statusText, +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("SubgraphClient retry with exponential backoff", () => { + it("retries transient 5xx errors up to 3 times by default, then succeeds", async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(errorResponse(503, "Service Unavailable")) + .mockResolvedValueOnce(errorResponse(502, "Bad Gateway")) + .mockResolvedValueOnce(okResponse({ traders: [] })); + + const client = new SubgraphClient({ fetchFn: mockFetch as any, retryDelay: 0 }); + const data = await client.rawQuery<{ traders: unknown[] }>("query { traders { id } }"); + + expect(data).toEqual({ traders: [] }); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("gives up after the default 3 retries (4 attempts total) and throws", async () => { + const mockFetch = vi.fn().mockResolvedValue(errorResponse(500, "Internal Server Error")); + + const client = new SubgraphClient({ fetchFn: mockFetch as any, retryDelay: 0 }); + + await expect(client.rawQuery("query { _meta { block { number } } }")).rejects.toThrow( + WhiteChainError + ); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it("retries network failures (fetch rejection) and succeeds on a later attempt", async () => { + const mockFetch = vi + .fn() + .mockRejectedValueOnce(new Error("socket hang up")) + .mockResolvedValueOnce(okResponse({ trades: [] })); + + const client = new SubgraphClient({ fetchFn: mockFetch as any, retryDelay: 0 }); + const data = await client.rawQuery<{ trades: unknown[] }>("query { trades { id } }"); + + expect(data).toEqual({ trades: [] }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry 400 Bad Request", async () => { + const mockFetch = vi.fn().mockResolvedValue(errorResponse(400, "Bad Request")); + + const client = new SubgraphClient({ fetchFn: mockFetch as any, retryDelay: 0 }); + + await expect(client.rawQuery("query { malformed }")).rejects.toThrow( + /HTTP status 400/ + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does NOT retry GraphQL-level errors", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ errors: [{ message: "Unknown field 'foo'" }] }), + }); + + const client = new SubgraphClient({ fetchFn: mockFetch as any, retryDelay: 0 }); + + await expect(client.rawQuery("query { foo }")).rejects.toThrow(/GraphQL error/); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("allows opting out entirely with retries: 0", async () => { + const mockFetch = vi.fn().mockResolvedValue(errorResponse(503, "Service Unavailable")); + + const client = new SubgraphClient({ fetchFn: mockFetch as any, retries: 0 }); + + await expect(client.rawQuery("query { traders { id } }")).rejects.toThrow( + /HTTP status 503/ + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("waits exponentially longer between retries (500ms, 1s, 2s by default)", async () => { + vi.useFakeTimers(); + const mockFetch = vi.fn().mockResolvedValue(errorResponse(503, "Service Unavailable")); + + const client = new SubgraphClient({ fetchFn: mockFetch as any }); + const pending = client.rawQuery("query { traders { id } }").catch((e) => e); + + // Attempt 1 fires immediately. + await vi.advanceTimersByTimeAsync(0); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Retry 1 after 500ms: not at 499ms, yes at 500ms. + await vi.advanceTimersByTimeAsync(499); + expect(mockFetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Retry 2 after another 1000ms. + await vi.advanceTimersByTimeAsync(999); + expect(mockFetch).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(mockFetch).toHaveBeenCalledTimes(3); + + // Retry 3 after another 2000ms. + await vi.advanceTimersByTimeAsync(1999); + expect(mockFetch).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(1); + expect(mockFetch).toHaveBeenCalledTimes(4); + + const err = await pending; + expect(err).toBeInstanceOf(WhiteChainError); + expect(String(err)).toMatch(/HTTP status 503/); + }); + + it("respects a custom retryDelay base for the backoff schedule", async () => { + vi.useFakeTimers(); + const mockFetch = vi + .fn() + .mockResolvedValueOnce(errorResponse(500, "Internal Server Error")) + .mockResolvedValueOnce(okResponse({ ok: true })); + + const client = new SubgraphClient({ + fetchFn: mockFetch as any, + retries: 1, + retryDelay: 100, + }); + const pending = client.rawQuery("query { traders { id } }"); + + await vi.advanceTimersByTimeAsync(0); + expect(mockFetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(100); + expect(mockFetch).toHaveBeenCalledTimes(2); + + await expect(pending).resolves.toEqual({ ok: true }); + }); +});