Skip to content

Commit c4f32b5

Browse files
ckorhonendevin-ai-integration[bot]claude
authored
feat: add retry with exponential backoff in client.ts (DIS-143) (#37)
* feat: add retry with exponential backoff in client.ts (DIS-143) Co-Authored-By: Chris K <ckorhonen@gmail.com> * fix: cancel response body on retryable errors to prevent resource leak Co-Authored-By: Chris K <ckorhonen@gmail.com> * fix: create fresh AbortSignal per retry attempt to prevent stale timeout Co-Authored-By: Chris K <ckorhonen@gmail.com> * fix: address code review feedback (POST idempotency, SDK default, stream cancel safety) Co-Authored-By: Chris K <ckorhonen@gmail.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f3d2c7a commit c4f32b5

File tree

7 files changed

+315
-30
lines changed

7 files changed

+315
-30
lines changed

src/cli.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ program
4848
"Comma-separated list of fields to include in output",
4949
)
5050
.option("--max-lines <lines>", "Truncate output after N lines")
51+
.option("--max-retries <n>", "Max retries on 429/5xx (0 to disable)", "3")
52+
.option("--no-retry", "Disable request retries")
5153

5254
function getClient(): OpenSeaClient {
5355
const opts = program.opts<{
@@ -56,6 +58,8 @@ function getClient(): OpenSeaClient {
5658
baseUrl?: string
5759
timeout: string
5860
verbose?: boolean
61+
maxRetries: string
62+
retry: boolean
5963
}>()
6064

6165
const apiKey = opts.apiKey ?? process.env.OPENSEA_API_KEY
@@ -66,12 +70,17 @@ function getClient(): OpenSeaClient {
6670
process.exit(EXIT_AUTH_ERROR)
6771
}
6872

73+
const maxRetries = opts.retry
74+
? parseIntOption(opts.maxRetries, "--max-retries")
75+
: 0
76+
6977
return new OpenSeaClient({
7078
apiKey,
7179
chain: opts.chain,
7280
baseUrl: opts.baseUrl,
7381
timeout: parseIntOption(opts.timeout, "--timeout"),
7482
verbose: opts.verbose,
83+
maxRetries,
7584
})
7685
}
7786

src/client.ts

Lines changed: 92 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,44 @@ declare const __VERSION__: string
55
const DEFAULT_BASE_URL = "https://api.opensea.io"
66
const DEFAULT_TIMEOUT_MS = 30_000
77
const USER_AGENT = `opensea-cli/${__VERSION__}`
8+
const DEFAULT_MAX_RETRIES = 0
9+
const DEFAULT_RETRY_BASE_DELAY_MS = 1_000
10+
11+
function isRetryableStatus(status: number, method: string): boolean {
12+
if (status === 429) return true
13+
return status >= 500 && method === "GET"
14+
}
15+
16+
function parseRetryAfter(header: string | null): number | undefined {
17+
if (!header) return undefined
18+
const seconds = Number(header)
19+
if (!Number.isNaN(seconds)) return seconds * 1000
20+
const date = Date.parse(header)
21+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
22+
return undefined
23+
}
24+
25+
function sleep(ms: number): Promise<void> {
26+
return new Promise(resolve => setTimeout(resolve, ms))
27+
}
828

929
export class OpenSeaClient {
1030
private apiKey: string
1131
private baseUrl: string
1232
private defaultChain: string
1333
private timeoutMs: number
1434
private verbose: boolean
35+
private maxRetries: number
36+
private retryBaseDelay: number
1537

1638
constructor(config: OpenSeaClientConfig) {
1739
this.apiKey = config.apiKey
1840
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL
1941
this.defaultChain = config.chain ?? "ethereum"
2042
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS
2143
this.verbose = config.verbose ?? false
44+
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES
45+
this.retryBaseDelay = config.retryBaseDelay ?? DEFAULT_RETRY_BASE_DELAY_MS
2246
}
2347

2448
private get defaultHeaders(): Record<string, string> {
@@ -44,20 +68,14 @@ export class OpenSeaClient {
4468
console.error(`[verbose] GET ${url.toString()}`)
4569
}
4670

47-
const response = await fetch(url.toString(), {
48-
method: "GET",
49-
headers: this.defaultHeaders,
50-
signal: AbortSignal.timeout(this.timeoutMs),
51-
})
52-
53-
if (this.verbose) {
54-
console.error(`[verbose] ${response.status} ${response.statusText}`)
55-
}
56-
57-
if (!response.ok) {
58-
const body = await response.text()
59-
throw new OpenSeaAPIError(response.status, body, path)
60-
}
71+
const response = await this.fetchWithRetry(
72+
url.toString(),
73+
{
74+
method: "GET",
75+
headers: this.defaultHeaders,
76+
},
77+
path,
78+
)
6179

6280
return response.json() as Promise<T>
6381
}
@@ -87,21 +105,15 @@ export class OpenSeaClient {
87105
console.error(`[verbose] POST ${url.toString()}`)
88106
}
89107

90-
const response = await fetch(url.toString(), {
91-
method: "POST",
92-
headers,
93-
body: body ? JSON.stringify(body) : undefined,
94-
signal: AbortSignal.timeout(this.timeoutMs),
95-
})
96-
97-
if (this.verbose) {
98-
console.error(`[verbose] ${response.status} ${response.statusText}`)
99-
}
100-
101-
if (!response.ok) {
102-
const text = await response.text()
103-
throw new OpenSeaAPIError(response.status, text, path)
104-
}
108+
const response = await this.fetchWithRetry(
109+
url.toString(),
110+
{
111+
method: "POST",
112+
headers,
113+
body: body ? JSON.stringify(body) : undefined,
114+
},
115+
path,
116+
)
105117

106118
return response.json() as Promise<T>
107119
}
@@ -114,6 +126,57 @@ export class OpenSeaClient {
114126
if (this.apiKey.length < 8) return "***"
115127
return `${this.apiKey.slice(0, 4)}...`
116128
}
129+
130+
private async fetchWithRetry(
131+
url: string,
132+
init: RequestInit,
133+
path: string,
134+
): Promise<Response> {
135+
for (let attempt = 0; ; attempt++) {
136+
const response = await fetch(url, {
137+
...init,
138+
signal: AbortSignal.timeout(this.timeoutMs),
139+
})
140+
141+
if (this.verbose) {
142+
console.error(`[verbose] ${response.status} ${response.statusText}`)
143+
}
144+
145+
if (response.ok) {
146+
return response
147+
}
148+
149+
const method = init.method ?? "GET"
150+
if (
151+
attempt < this.maxRetries &&
152+
isRetryableStatus(response.status, method)
153+
) {
154+
const retryAfterMs = parseRetryAfter(
155+
response.headers.get("Retry-After"),
156+
)
157+
const backoffMs = this.retryBaseDelay * 2 ** attempt
158+
const jitterMs = Math.random() * this.retryBaseDelay
159+
const delayMs = Math.max(retryAfterMs ?? 0, backoffMs) + jitterMs
160+
161+
if (this.verbose) {
162+
console.error(
163+
`[verbose] Retry ${attempt + 1}/${this.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`,
164+
)
165+
}
166+
167+
try {
168+
await response.body?.cancel()
169+
} catch {
170+
// Stream may already be disturbed
171+
}
172+
await sleep(delayMs)
173+
continue
174+
}
175+
176+
const text = await response.text()
177+
throw new OpenSeaAPIError(response.status, text, path)
178+
}
179+
}
117180
}
118181

119182
export class OpenSeaAPIError extends Error {

src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface OpenSeaClientConfig {
66
chain?: string
77
timeout?: number
88
verbose?: boolean
9+
maxRetries?: number
10+
retryBaseDelay?: number
911
}
1012

1113
export interface CommandOptions {

test/cli-api-error.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock("../src/commands/index.js", () => ({
1212
searchCommand: () => new Command("search"),
1313
swapsCommand: () => new Command("swaps"),
1414
tokensCommand: () => new Command("tokens"),
15+
healthCommand: () => new Command("health"),
1516
}))
1617

1718
const exitSpy = vi

test/cli-network-error.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ vi.mock("../src/commands/index.js", () => ({
1111
searchCommand: () => new Command("search"),
1212
swapsCommand: () => new Command("swaps"),
1313
tokensCommand: () => new Command("tokens"),
14+
healthCommand: () => new Command("health"),
1415
}))
1516

1617
const exitSpy = vi

test/cli-rate-limit.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock("../src/commands/index.js", () => ({
1212
searchCommand: () => new Command("search"),
1313
swapsCommand: () => new Command("swaps"),
1414
tokensCommand: () => new Command("tokens"),
15+
healthCommand: () => new Command("health"),
1516
}))
1617

1718
const exitSpy = vi

0 commit comments

Comments
 (0)