Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand All @@ -96,7 +98,5 @@
"snarkjs": {
"optional": true
}
"vitest": "^1.3.1",
"ws": "^8.16.0"
}
}
75 changes: 57 additions & 18 deletions src/subgraph/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,22 @@ import type {
export const DEFAULT_SUBGRAPH_URL =
"https://api.thegraph.com/subgraphs/name/whitechain/mainnet";

function sleep(ms: number): Promise<void> {
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) {
Expand All @@ -38,41 +46,72 @@ 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<TData = any, TVariables = Record<string, any>>(
query: string,
variables?: TVariables
): Promise<TData> {
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("; ");
throw new WhiteChainError(`Subgraph GraphQL error: ${errorMessages}`);
}

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);
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/subgraph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
148 changes: 148 additions & 0 deletions tests/subgraph/ClientRetry.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});