From 5646ecbc56f507b8bee416b369094e05c180040f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:09:15 +0000 Subject: [PATCH 1/5] feat: add retry with exponential backoff in client.ts (DIS-143) Co-Authored-By: Chris K --- src/cli.ts | 9 ++ src/client.ts | 113 +++++++++++++++++++------- src/types/index.ts | 2 + test/client.test.ts | 194 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 288 insertions(+), 30 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 4c3d0ff..02da4c3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,6 +48,8 @@ program "Comma-separated list of fields to include in output", ) .option("--max-lines ", "Truncate output after N lines") + .option("--max-retries ", "Max retries on 429/5xx (0 to disable)", "3") + .option("--no-retry", "Disable request retries") function getClient(): OpenSeaClient { const opts = program.opts<{ @@ -56,6 +58,8 @@ function getClient(): OpenSeaClient { baseUrl?: string timeout: string verbose?: boolean + maxRetries: string + retry: boolean }>() const apiKey = opts.apiKey ?? process.env.OPENSEA_API_KEY @@ -66,12 +70,17 @@ function getClient(): OpenSeaClient { process.exit(EXIT_AUTH_ERROR) } + const maxRetries = opts.retry + ? parseIntOption(opts.maxRetries, "--max-retries") + : 0 + return new OpenSeaClient({ apiKey, chain: opts.chain, baseUrl: opts.baseUrl, timeout: parseIntOption(opts.timeout, "--timeout"), verbose: opts.verbose, + maxRetries, }) } diff --git a/src/client.ts b/src/client.ts index ba2316d..683bcb0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,6 +5,25 @@ declare const __VERSION__: string const DEFAULT_BASE_URL = "https://api.opensea.io" const DEFAULT_TIMEOUT_MS = 30_000 const USER_AGENT = `opensea-cli/${__VERSION__}` +const DEFAULT_MAX_RETRIES = 3 +const DEFAULT_RETRY_BASE_DELAY_MS = 1_000 + +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500 +} + +function parseRetryAfter(header: string | null): number | undefined { + if (!header) return undefined + const seconds = Number(header) + if (!Number.isNaN(seconds)) return seconds * 1000 + const date = Date.parse(header) + if (!Number.isNaN(date)) return Math.max(0, date - Date.now()) + return undefined +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} export class OpenSeaClient { private apiKey: string @@ -12,6 +31,8 @@ export class OpenSeaClient { private defaultChain: string private timeoutMs: number private verbose: boolean + private maxRetries: number + private retryBaseDelay: number constructor(config: OpenSeaClientConfig) { this.apiKey = config.apiKey @@ -19,6 +40,8 @@ export class OpenSeaClient { this.defaultChain = config.chain ?? "ethereum" this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS this.verbose = config.verbose ?? false + this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES + this.retryBaseDelay = config.retryBaseDelay ?? DEFAULT_RETRY_BASE_DELAY_MS } private get defaultHeaders(): Record { @@ -44,20 +67,15 @@ export class OpenSeaClient { console.error(`[verbose] GET ${url.toString()}`) } - const response = await fetch(url.toString(), { - method: "GET", - headers: this.defaultHeaders, - signal: AbortSignal.timeout(this.timeoutMs), - }) - - if (this.verbose) { - console.error(`[verbose] ${response.status} ${response.statusText}`) - } - - if (!response.ok) { - const body = await response.text() - throw new OpenSeaAPIError(response.status, body, path) - } + const response = await this.fetchWithRetry( + url.toString(), + { + method: "GET", + headers: this.defaultHeaders, + signal: AbortSignal.timeout(this.timeoutMs), + }, + path, + ) return response.json() as Promise } @@ -87,21 +105,16 @@ export class OpenSeaClient { console.error(`[verbose] POST ${url.toString()}`) } - const response = await fetch(url.toString(), { - method: "POST", - headers, - body: body ? JSON.stringify(body) : undefined, - signal: AbortSignal.timeout(this.timeoutMs), - }) - - if (this.verbose) { - console.error(`[verbose] ${response.status} ${response.statusText}`) - } - - if (!response.ok) { - const text = await response.text() - throw new OpenSeaAPIError(response.status, text, path) - } + const response = await this.fetchWithRetry( + url.toString(), + { + method: "POST", + headers, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(this.timeoutMs), + }, + path, + ) return response.json() as Promise } @@ -114,6 +127,48 @@ export class OpenSeaClient { if (this.apiKey.length < 8) return "***" return `${this.apiKey.slice(0, 4)}...` } + + private async fetchWithRetry( + url: string, + init: RequestInit, + path: string, + ): Promise { + for (let attempt = 0; ; attempt++) { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(this.timeoutMs), + }) + + if (this.verbose) { + console.error(`[verbose] ${response.status} ${response.statusText}`) + } + + if (response.ok) { + return response + } + + if (attempt < this.maxRetries && isRetryableStatus(response.status)) { + const retryAfterMs = parseRetryAfter( + response.headers.get("Retry-After"), + ) + const backoffMs = this.retryBaseDelay * 2 ** attempt + const jitterMs = Math.random() * this.retryBaseDelay + const delayMs = Math.max(retryAfterMs ?? 0, backoffMs) + jitterMs + + if (this.verbose) { + console.error( + `[verbose] Retry ${attempt + 1}/${this.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`, + ) + } + + await sleep(delayMs) + continue + } + + const text = await response.text() + throw new OpenSeaAPIError(response.status, text, path) + } + } } export class OpenSeaAPIError extends Error { diff --git a/src/types/index.ts b/src/types/index.ts index ad55e6e..ca1dd27 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,8 @@ export interface OpenSeaClientConfig { chain?: string timeout?: number verbose?: boolean + maxRetries?: number + retryBaseDelay?: number } export interface CommandOptions { diff --git a/test/client.test.ts b/test/client.test.ts index 1e6f237..3227d4a 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -6,7 +6,7 @@ describe("OpenSeaClient", () => { let client: OpenSeaClient beforeEach(() => { - client = new OpenSeaClient({ apiKey: "test-key" }) + client = new OpenSeaClient({ apiKey: "test-key", maxRetries: 0 }) }) afterEach(() => { @@ -143,6 +143,198 @@ describe("OpenSeaClient", () => { }) }) + describe("retry", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("retries on 429 and succeeds", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 3, + retryBaseDelay: 100, + }) + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("Rate limited", { status: 429 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ) + + const promise = retryClient.get("/api/v2/test") + await vi.advanceTimersByTimeAsync(10_000) + const result = await promise + + expect(result).toEqual({ ok: true }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it("retries on 500 and succeeds", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 3, + retryBaseDelay: 100, + }) + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("Server Error", { status: 500 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ) + + const promise = retryClient.get("/api/v2/test") + await vi.advanceTimersByTimeAsync(10_000) + const result = await promise + + expect(result).toEqual({ ok: true }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it("throws after exhausting all retries", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 2, + retryBaseDelay: 100, + }) + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response("Rate limited", { status: 429 })), + ) + + const promise = retryClient.get("/api/v2/test").catch((e: unknown) => e) + await vi.advanceTimersByTimeAsync(60_000) + const error = await promise + + expect(error).toBeInstanceOf(OpenSeaAPIError) + expect(fetch).toHaveBeenCalledTimes(3) + }) + + it("does not retry on 404", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 3, + retryBaseDelay: 100, + }) + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Not Found", { status: 404 }), + ) + + await expect(retryClient.get("/api/v2/test")).rejects.toThrow( + OpenSeaAPIError, + ) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it("does not retry when maxRetries is 0", async () => { + const noRetryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 0, + }) + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Rate limited", { status: 429 }), + ) + + await expect(noRetryClient.get("/api/v2/test")).rejects.toThrow( + OpenSeaAPIError, + ) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it("respects Retry-After header (seconds)", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 1, + retryBaseDelay: 100, + }) + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response("Rate limited", { + status: 429, + headers: { "Retry-After": "5" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ) + + const promise = retryClient.get("/api/v2/test") + // Advance past the 5s Retry-After + jitter + await vi.advanceTimersByTimeAsync(10_000) + const result = await promise + + expect(result).toEqual({ ok: true }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it("retries post requests", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 3, + retryBaseDelay: 100, + }) + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("Server Error", { status: 503 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ status: "ok" }), { + status: 200, + }), + ) + + const promise = retryClient.post("/api/v2/refresh") + await vi.advanceTimersByTimeAsync(10_000) + const result = await promise + + expect(result).toEqual({ status: "ok" }) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it("logs retries when verbose is enabled", async () => { + const verboseRetryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 3, + retryBaseDelay: 100, + verbose: true, + }) + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("Rate limited", { status: 429 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ) + + const promise = verboseRetryClient.get("/api/v2/test") + await vi.advanceTimersByTimeAsync(10_000) + await promise + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringMatching( + /\[verbose\] Retry 1\/3 after \d+ms \(status 429\)/, + ), + ) + }) + + it("does not log retries when verbose is disabled", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 3, + retryBaseDelay: 100, + }) + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("Rate limited", { status: 429 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ) + + const promise = retryClient.get("/api/v2/test") + await vi.advanceTimersByTimeAsync(10_000) + await promise + + expect(stderrSpy).not.toHaveBeenCalled() + }) + }) + describe("timeout", () => { it("passes AbortSignal.timeout to fetch calls", async () => { const timedClient = new OpenSeaClient({ From 8b200e1b0cc809fc2a8a8f4bf62244ff51ba67f5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:14:33 +0000 Subject: [PATCH 2/5] fix: cancel response body on retryable errors to prevent resource leak Co-Authored-By: Chris K --- src/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client.ts b/src/client.ts index 683bcb0..e48fc89 100644 --- a/src/client.ts +++ b/src/client.ts @@ -161,6 +161,7 @@ export class OpenSeaClient { ) } + await response.body?.cancel() await sleep(delayMs) continue } From 21494bd6353acc3b64b336dd2a57159c5020dde4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:20:32 +0000 Subject: [PATCH 3/5] fix: create fresh AbortSignal per retry attempt to prevent stale timeout Co-Authored-By: Chris K --- src/client.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index e48fc89..6b37298 100644 --- a/src/client.ts +++ b/src/client.ts @@ -72,7 +72,6 @@ export class OpenSeaClient { { method: "GET", headers: this.defaultHeaders, - signal: AbortSignal.timeout(this.timeoutMs), }, path, ) @@ -111,7 +110,6 @@ export class OpenSeaClient { method: "POST", headers, body: body ? JSON.stringify(body) : undefined, - signal: AbortSignal.timeout(this.timeoutMs), }, path, ) From 3e71a905c042f9d681dd19a88525ad513b0a3dbb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:24:14 +0000 Subject: [PATCH 4/5] fix: address code review feedback (POST idempotency, SDK default, stream cancel safety) Co-Authored-By: Chris K --- src/client.ts | 19 ++++++++++++++----- test/client.test.ts | 20 ++++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/client.ts b/src/client.ts index 6b37298..bbb48a9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,11 +5,12 @@ declare const __VERSION__: string const DEFAULT_BASE_URL = "https://api.opensea.io" const DEFAULT_TIMEOUT_MS = 30_000 const USER_AGENT = `opensea-cli/${__VERSION__}` -const DEFAULT_MAX_RETRIES = 3 +const DEFAULT_MAX_RETRIES = 0 const DEFAULT_RETRY_BASE_DELAY_MS = 1_000 -function isRetryableStatus(status: number): boolean { - return status === 429 || status >= 500 +function isRetryableStatus(status: number, method: string): boolean { + if (status === 429) return true + return status >= 500 && method === "GET" } function parseRetryAfter(header: string | null): number | undefined { @@ -145,7 +146,11 @@ export class OpenSeaClient { return response } - if (attempt < this.maxRetries && isRetryableStatus(response.status)) { + const method = init.method ?? "GET" + if ( + attempt < this.maxRetries && + isRetryableStatus(response.status, method) + ) { const retryAfterMs = parseRetryAfter( response.headers.get("Retry-After"), ) @@ -159,7 +164,11 @@ export class OpenSeaClient { ) } - await response.body?.cancel() + try { + await response.body?.cancel() + } catch { + // Stream may already be disturbed + } await sleep(delayMs) continue } diff --git a/test/client.test.ts b/test/client.test.ts index 3227d4a..489f1db 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -267,14 +267,14 @@ describe("OpenSeaClient", () => { expect(fetch).toHaveBeenCalledTimes(2) }) - it("retries post requests", async () => { + it("retries post requests on 429", async () => { const retryClient = new OpenSeaClient({ apiKey: "test-key", maxRetries: 3, retryBaseDelay: 100, }) vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(new Response("Server Error", { status: 503 })) + .mockResolvedValueOnce(new Response("Rate limited", { status: 429 })) .mockResolvedValueOnce( new Response(JSON.stringify({ status: "ok" }), { status: 200, @@ -289,6 +289,22 @@ describe("OpenSeaClient", () => { expect(fetch).toHaveBeenCalledTimes(2) }) + it("does not retry post requests on 5xx", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + maxRetries: 3, + retryBaseDelay: 100, + }) + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Server Error", { status: 503 }), + ) + + await expect(retryClient.post("/api/v2/refresh")).rejects.toThrow( + OpenSeaAPIError, + ) + expect(fetch).toHaveBeenCalledTimes(1) + }) + it("logs retries when verbose is enabled", async () => { const verboseRetryClient = new OpenSeaClient({ apiKey: "test-key", From e4a6fbc5b0b8e6b116c2d1c428af6d3795ffb3d5 Mon Sep 17 00:00:00 2001 From: Chris Korhonen Date: Wed, 4 Mar 2026 15:42:58 -0500 Subject: [PATCH 5/5] fix: add healthCommand mock to CLI error handler tests The healthCommand was added to src/commands/index.ts via PR #35 but these test files were not updated to include it in their mock. Co-Authored-By: Claude Opus 4.6 --- test/cli-api-error.test.ts | 1 + test/cli-network-error.test.ts | 1 + test/cli-rate-limit.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/test/cli-api-error.test.ts b/test/cli-api-error.test.ts index fbf621e..43c988b 100644 --- a/test/cli-api-error.test.ts +++ b/test/cli-api-error.test.ts @@ -12,6 +12,7 @@ vi.mock("../src/commands/index.js", () => ({ searchCommand: () => new Command("search"), swapsCommand: () => new Command("swaps"), tokensCommand: () => new Command("tokens"), + healthCommand: () => new Command("health"), })) const exitSpy = vi diff --git a/test/cli-network-error.test.ts b/test/cli-network-error.test.ts index 5d4266d..29c9adf 100644 --- a/test/cli-network-error.test.ts +++ b/test/cli-network-error.test.ts @@ -11,6 +11,7 @@ vi.mock("../src/commands/index.js", () => ({ searchCommand: () => new Command("search"), swapsCommand: () => new Command("swaps"), tokensCommand: () => new Command("tokens"), + healthCommand: () => new Command("health"), })) const exitSpy = vi diff --git a/test/cli-rate-limit.test.ts b/test/cli-rate-limit.test.ts index bbadf0d..ff35091 100644 --- a/test/cli-rate-limit.test.ts +++ b/test/cli-rate-limit.test.ts @@ -12,6 +12,7 @@ vi.mock("../src/commands/index.js", () => ({ searchCommand: () => new Command("search"), swapsCommand: () => new Command("swaps"), tokensCommand: () => new Command("tokens"), + healthCommand: () => new Command("health"), })) const exitSpy = vi