Skip to content

Commit 5646ecb

Browse files
feat: add retry with exponential backoff in client.ts (DIS-143)
Co-Authored-By: Chris K <ckorhonen@gmail.com>
1 parent f3d2c7a commit 5646ecb

4 files changed

Lines changed: 288 additions & 30 deletions

File tree

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: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,43 @@ 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 = 3
9+
const DEFAULT_RETRY_BASE_DELAY_MS = 1_000
10+
11+
function isRetryableStatus(status: number): boolean {
12+
return status === 429 || status >= 500
13+
}
14+
15+
function parseRetryAfter(header: string | null): number | undefined {
16+
if (!header) return undefined
17+
const seconds = Number(header)
18+
if (!Number.isNaN(seconds)) return seconds * 1000
19+
const date = Date.parse(header)
20+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
21+
return undefined
22+
}
23+
24+
function sleep(ms: number): Promise<void> {
25+
return new Promise(resolve => setTimeout(resolve, ms))
26+
}
827

928
export class OpenSeaClient {
1029
private apiKey: string
1130
private baseUrl: string
1231
private defaultChain: string
1332
private timeoutMs: number
1433
private verbose: boolean
34+
private maxRetries: number
35+
private retryBaseDelay: number
1536

1637
constructor(config: OpenSeaClientConfig) {
1738
this.apiKey = config.apiKey
1839
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL
1940
this.defaultChain = config.chain ?? "ethereum"
2041
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS
2142
this.verbose = config.verbose ?? false
43+
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES
44+
this.retryBaseDelay = config.retryBaseDelay ?? DEFAULT_RETRY_BASE_DELAY_MS
2245
}
2346

2447
private get defaultHeaders(): Record<string, string> {
@@ -44,20 +67,15 @@ export class OpenSeaClient {
4467
console.error(`[verbose] GET ${url.toString()}`)
4568
}
4669

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-
}
70+
const response = await this.fetchWithRetry(
71+
url.toString(),
72+
{
73+
method: "GET",
74+
headers: this.defaultHeaders,
75+
signal: AbortSignal.timeout(this.timeoutMs),
76+
},
77+
path,
78+
)
6179

6280
return response.json() as Promise<T>
6381
}
@@ -87,21 +105,16 @@ 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+
signal: AbortSignal.timeout(this.timeoutMs),
115+
},
116+
path,
117+
)
105118

106119
return response.json() as Promise<T>
107120
}
@@ -114,6 +127,48 @@ export class OpenSeaClient {
114127
if (this.apiKey.length < 8) return "***"
115128
return `${this.apiKey.slice(0, 4)}...`
116129
}
130+
131+
private async fetchWithRetry(
132+
url: string,
133+
init: RequestInit,
134+
path: string,
135+
): Promise<Response> {
136+
for (let attempt = 0; ; attempt++) {
137+
const response = await fetch(url, {
138+
...init,
139+
signal: AbortSignal.timeout(this.timeoutMs),
140+
})
141+
142+
if (this.verbose) {
143+
console.error(`[verbose] ${response.status} ${response.statusText}`)
144+
}
145+
146+
if (response.ok) {
147+
return response
148+
}
149+
150+
if (attempt < this.maxRetries && isRetryableStatus(response.status)) {
151+
const retryAfterMs = parseRetryAfter(
152+
response.headers.get("Retry-After"),
153+
)
154+
const backoffMs = this.retryBaseDelay * 2 ** attempt
155+
const jitterMs = Math.random() * this.retryBaseDelay
156+
const delayMs = Math.max(retryAfterMs ?? 0, backoffMs) + jitterMs
157+
158+
if (this.verbose) {
159+
console.error(
160+
`[verbose] Retry ${attempt + 1}/${this.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`,
161+
)
162+
}
163+
164+
await sleep(delayMs)
165+
continue
166+
}
167+
168+
const text = await response.text()
169+
throw new OpenSeaAPIError(response.status, text, path)
170+
}
171+
}
117172
}
118173

119174
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 {

0 commit comments

Comments
 (0)