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..bbb48a9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,6 +5,26 @@ 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 = 0 +const DEFAULT_RETRY_BASE_DELAY_MS = 1_000 + +function isRetryableStatus(status: number, method: string): boolean { + if (status === 429) return true + return status >= 500 && method === "GET" +} + +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 +32,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 +41,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 +68,14 @@ 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, + }, + path, + ) return response.json() as Promise } @@ -87,21 +105,15 @@ 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, + }, + path, + ) return response.json() as Promise } @@ -114,6 +126,57 @@ 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 + } + + const method = init.method ?? "GET" + if ( + attempt < this.maxRetries && + isRetryableStatus(response.status, method) + ) { + 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})`, + ) + } + + try { + await response.body?.cancel() + } catch { + // Stream may already be disturbed + } + 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/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 diff --git a/test/client.test.ts b/test/client.test.ts index 1e6f237..489f1db 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,214 @@ 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 on 429", 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({ 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("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", + 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({