Skip to content

Commit ae2e887

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

4 files changed

Lines changed: 287 additions & 32 deletions

File tree

src/cli.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ program
3838
.option("--base-url <url>", "API base URL")
3939
.option("--timeout <ms>", "Request timeout in milliseconds", "30000")
4040
.option("--verbose", "Log request and response info to stderr")
41+
.option("--max-retries <n>", "Max retries on 429/5xx (0 to disable)", "3")
42+
.option("--no-retry", "Disable request retries")
4143

4244
function getClient(): OpenSeaClient {
4345
const opts = program.opts<{
@@ -46,6 +48,8 @@ function getClient(): OpenSeaClient {
4648
baseUrl?: string
4749
timeout: string
4850
verbose?: boolean
51+
maxRetries: string
52+
retry: boolean
4953
}>()
5054

5155
const apiKey = opts.apiKey ?? process.env.OPENSEA_API_KEY
@@ -56,12 +60,17 @@ function getClient(): OpenSeaClient {
5660
process.exit(2)
5761
}
5862

63+
const maxRetries = opts.retry
64+
? parseIntOption(opts.maxRetries, "--max-retries")
65+
: 0
66+
5967
return new OpenSeaClient({
6068
apiKey,
6169
chain: opts.chain,
6270
baseUrl: opts.baseUrl,
6371
timeout: parseIntOption(opts.timeout, "--timeout"),
6472
verbose: opts.verbose,
73+
maxRetries,
6574
})
6675
}
6776

src/client.ts

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,43 @@ import type { OpenSeaClientConfig } from "./types/index.js"
22

33
const DEFAULT_BASE_URL = "https://api.opensea.io"
44
const DEFAULT_TIMEOUT_MS = 30_000
5+
const DEFAULT_MAX_RETRIES = 3
6+
const DEFAULT_RETRY_BASE_DELAY_MS = 1_000
7+
8+
function isRetryableStatus(status: number): boolean {
9+
return status === 429 || status >= 500
10+
}
11+
12+
function parseRetryAfter(header: string | null): number | undefined {
13+
if (!header) return undefined
14+
const seconds = Number(header)
15+
if (!Number.isNaN(seconds)) return seconds * 1000
16+
const date = Date.parse(header)
17+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
18+
return undefined
19+
}
20+
21+
function sleep(ms: number): Promise<void> {
22+
return new Promise(resolve => setTimeout(resolve, ms))
23+
}
524

625
export class OpenSeaClient {
726
private apiKey: string
827
private baseUrl: string
928
private defaultChain: string
1029
private timeoutMs: number
1130
private verbose: boolean
31+
private maxRetries: number
32+
private retryBaseDelay: number
1233

1334
constructor(config: OpenSeaClientConfig) {
1435
this.apiKey = config.apiKey
1536
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL
1637
this.defaultChain = config.chain ?? "ethereum"
1738
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS
1839
this.verbose = config.verbose ?? false
40+
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES
41+
this.retryBaseDelay = config.retryBaseDelay ?? DEFAULT_RETRY_BASE_DELAY_MS
1942
}
2043

2144
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
@@ -33,23 +56,18 @@ export class OpenSeaClient {
3356
console.error(`[verbose] GET ${url.toString()}`)
3457
}
3558

36-
const response = await fetch(url.toString(), {
37-
method: "GET",
38-
headers: {
39-
Accept: "application/json",
40-
"x-api-key": this.apiKey,
59+
const response = await this.fetchWithRetry(
60+
url.toString(),
61+
{
62+
method: "GET",
63+
headers: {
64+
Accept: "application/json",
65+
"x-api-key": this.apiKey,
66+
},
67+
signal: AbortSignal.timeout(this.timeoutMs),
4168
},
42-
signal: AbortSignal.timeout(this.timeoutMs),
43-
})
44-
45-
if (this.verbose) {
46-
console.error(`[verbose] ${response.status} ${response.statusText}`)
47-
}
48-
49-
if (!response.ok) {
50-
const body = await response.text()
51-
throw new OpenSeaAPIError(response.status, body, path)
52-
}
69+
path,
70+
)
5371

5472
return response.json() as Promise<T>
5573
}
@@ -82,28 +100,62 @@ export class OpenSeaClient {
82100
console.error(`[verbose] POST ${url.toString()}`)
83101
}
84102

85-
const response = await fetch(url.toString(), {
86-
method: "POST",
87-
headers,
88-
body: body ? JSON.stringify(body) : undefined,
89-
signal: AbortSignal.timeout(this.timeoutMs),
90-
})
91-
92-
if (this.verbose) {
93-
console.error(`[verbose] ${response.status} ${response.statusText}`)
94-
}
95-
96-
if (!response.ok) {
97-
const text = await response.text()
98-
throw new OpenSeaAPIError(response.status, text, path)
99-
}
103+
const response = await this.fetchWithRetry(
104+
url.toString(),
105+
{
106+
method: "POST",
107+
headers,
108+
body: body ? JSON.stringify(body) : undefined,
109+
signal: AbortSignal.timeout(this.timeoutMs),
110+
},
111+
path,
112+
)
100113

101114
return response.json() as Promise<T>
102115
}
103116

104117
getDefaultChain(): string {
105118
return this.defaultChain
106119
}
120+
121+
private async fetchWithRetry(
122+
url: string,
123+
init: RequestInit,
124+
path: string,
125+
): Promise<Response> {
126+
for (let attempt = 0; ; attempt++) {
127+
const response = await fetch(url, init)
128+
129+
if (this.verbose) {
130+
console.error(`[verbose] ${response.status} ${response.statusText}`)
131+
}
132+
133+
if (response.ok) {
134+
return response
135+
}
136+
137+
if (attempt < this.maxRetries && isRetryableStatus(response.status)) {
138+
const retryAfterMs = parseRetryAfter(
139+
response.headers.get("Retry-After"),
140+
)
141+
const backoffMs = this.retryBaseDelay * 2 ** attempt
142+
const jitterMs = Math.random() * this.retryBaseDelay
143+
const delayMs = Math.max(retryAfterMs ?? 0, backoffMs) + jitterMs
144+
145+
if (this.verbose) {
146+
console.error(
147+
`[verbose] Retry ${attempt + 1}/${this.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`,
148+
)
149+
}
150+
151+
await sleep(delayMs)
152+
continue
153+
}
154+
155+
const text = await response.text()
156+
throw new OpenSeaAPIError(response.status, text, path)
157+
}
158+
}
107159
}
108160

109161
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)