@@ -2,20 +2,43 @@ import type { OpenSeaClientConfig } from "./types/index.js"
22
33const DEFAULT_BASE_URL = "https://api.opensea.io"
44const 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
625export 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
109161export class OpenSeaAPIError extends Error {
0 commit comments