@@ -5,20 +5,43 @@ declare const __VERSION__: string
55const DEFAULT_BASE_URL = "https://api.opensea.io"
66const DEFAULT_TIMEOUT_MS = 30_000
77const 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
928export 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
119174export class OpenSeaAPIError extends Error {
0 commit comments