@@ -6,20 +6,23 @@ import {
66 getOSName ,
77} from './utils'
88
9- const axios = require ( 'axios' )
9+ import axios , { AxiosRequestConfig , AxiosResponse } from 'axios'
1010/** Abstract parent class for all API Poll Bots */
1111export class APIPollBot {
1212 apiEndpoint : string
1313 refreshRateMs : number
14+ baseRefreshRateMs : number
1415 bot : Client
15- headers : any
16+ headers : Record < string , string >
1617 listColor : ColorResolvable
1718 saleColor : ColorResolvable
1819 sweepColor : ColorResolvable
1920 artblocksSaleColor : ColorResolvable
2021 artblocksListColor : ColorResolvable
2122 lastUpdatedTime : number
2223 intervalId ?: NodeJS . Timeout
24+ private consecutiveRateLimits = 0
25+ private isPolling = false
2326
2427 /**
2528 * Constructor
@@ -36,6 +39,7 @@ export class APIPollBot {
3639 ) {
3740 this . apiEndpoint = apiEndpoint
3841 this . refreshRateMs = refreshRateMs
42+ this . baseRefreshRateMs = refreshRateMs
3943 this . bot = bot
4044 this . headers = headers
4145 this . listColor = '#407FDB'
@@ -76,9 +80,15 @@ export class APIPollBot {
7680 }
7781
7882 /**
79- * Polls provided apiEndpoint with provided headers
83+ * Polls provided apiEndpoint with provided headers.
84+ * Skips if a previous poll is still in-flight (e.g. paginating).
8085 */
8186 async pollApi ( ) {
87+ if ( this . isPolling ) {
88+ console . log ( 'Skipping poll — previous poll still in-flight' )
89+ return
90+ }
91+ this . isPolling = true
8292 try {
8393 const response = await this . getWithRetry (
8494 this . apiEndpoint ,
@@ -89,84 +99,201 @@ export class APIPollBot {
8999 } ,
90100 3
91101 )
102+ if ( response . status === 429 ) {
103+ this . handleRateLimit ( )
104+ return
105+ }
92106 if ( response . status >= 400 ) {
93107 console . warn (
94108 `API poll non-2xx response (${ response . status } ) for ${ this . apiEndpoint } `
95109 )
96110 return
97111 }
112+ // Successful response — gradually recover polling rate
113+ this . recoverPollingRate ( )
98114 await this . handleAPIResponse ( response . data )
99115 } catch ( err ) {
100- const error = err as any
116+ const error = err as {
117+ response ?: { status ?: number ; statusText ?: string }
118+ message ?: string
119+ }
101120 const status = error ?. response ?. status
102121 const statusText = error ?. response ?. statusText
103122 const message = error ?. message
123+ if ( status === 429 ) {
124+ this . handleRateLimit ( )
125+ return
126+ }
104127 console . warn (
105128 `Error polling ${ this . apiEndpoint } - status: ${ status } ${ statusText } message: ${ message } `
106129 )
130+ } finally {
131+ this . isPolling = false
107132 }
108133 }
109134
135+ /**
136+ * Called when a 429 rate limit is encountered — slows down polling
137+ */
138+ private handleRateLimit ( ) {
139+ this . consecutiveRateLimits ++
140+ // Double the interval on each consecutive 429, up to 5 minutes
141+ const backoffMultiplier = Math . pow ( 2 , this . consecutiveRateLimits )
142+ const newRate = Math . min (
143+ this . baseRefreshRateMs * backoffMultiplier ,
144+ 5 * 60 * 1000
145+ )
146+ if ( newRate !== this . refreshRateMs ) {
147+ console . warn (
148+ `Rate limited (429) — slowing polling from ${ this . refreshRateMs } ms to ${ newRate } ms (${ this . consecutiveRateLimits } consecutive 429s)`
149+ )
150+ this . refreshRateMs = newRate
151+ this . restartPolling ( )
152+ }
153+ }
154+
155+ /**
156+ * Gradually recover polling rate after successful responses
157+ */
158+ private recoverPollingRate ( ) {
159+ if ( this . consecutiveRateLimits > 0 ) {
160+ this . consecutiveRateLimits = 0
161+ if ( this . refreshRateMs !== this . baseRefreshRateMs ) {
162+ console . log (
163+ `Rate limit cleared — restoring polling rate to ${ this . baseRefreshRateMs } ms`
164+ )
165+ this . refreshRateMs = this . baseRefreshRateMs
166+ this . restartPolling ( )
167+ }
168+ }
169+ }
170+
171+ /**
172+ * Restart polling with the current refreshRateMs
173+ */
174+ private restartPolling ( ) {
175+ this . stopPolling ( )
176+ this . startPolling ( )
177+ }
178+
110179 /**
111180 * Helper to GET with retries and backoff for transient network/server errors
181+ * Also handles 429 rate-limit responses with Retry-After header support
112182 */
113183 protected async getWithRetry (
114184 url : string ,
115- config : any ,
185+ config : AxiosRequestConfig ,
116186 retries = 3 ,
117187 initialDelayMs = 1000
118- ) : Promise < any > {
188+ ) : Promise < AxiosResponse > {
119189 let attempt = 0
120- let lastError : any
190+ let lastError : unknown
121191 while ( attempt <= retries ) {
122192 try {
123- return await axios . get ( url , config )
124- } catch ( err : any ) {
193+ const response = await axios . get ( url , config )
194+
195+ // Handle 429 returned as a non-throwing response (validateStatus)
196+ if ( response . status === 429 ) {
197+ if ( attempt === retries ) return response
198+ const retryAfter = this . parseRetryAfter ( response . headers )
199+ const delay =
200+ retryAfter ?? Math . min ( initialDelayMs * Math . pow ( 2 , attempt ) , 30000 )
201+ console . warn (
202+ `GET retry ${
203+ attempt + 1
204+ } /${ retries } for ${ url } - rate limited (429), waiting ${ delay } ms`
205+ )
206+ await new Promise ( ( res ) => setTimeout ( res , delay ) )
207+ attempt ++
208+ continue
209+ }
210+
211+ return response
212+ } catch ( err : unknown ) {
125213 lastError = err
126- const code = err ?. code || err ?. response ?. status
214+ const axiosErr = err as {
215+ code ?: string
216+ response ?: { status ?: number ; headers ?: Record < string , string > }
217+ message ?: string
218+ }
219+ const code = axiosErr ?. code || axiosErr ?. response ?. status
127220 const isTimeout =
128- err ?. code === 'ECONNABORTED' || / t i m e o u t / i. test ( err ?. message || '' )
129- const isReset = err ?. code === 'ECONNRESET'
130- const status = err ?. response ?. status
221+ axiosErr ?. code === 'ECONNABORTED' ||
222+ / t i m e o u t / i. test ( axiosErr ?. message || '' )
223+ const isReset = axiosErr ?. code === 'ECONNRESET'
224+ const status = axiosErr ?. response ?. status
225+ const isRateLimited = status === 429
131226 const shouldRetry =
132227 isTimeout ||
133228 isReset ||
229+ isRateLimited ||
134230 ( typeof status === 'number' && status >= 500 && status < 600 )
135231
136232 if ( ! shouldRetry || attempt === retries ) {
137233 break
138234 }
139235
140- const delay = Math . min ( initialDelayMs * Math . pow ( 2 , attempt ) , 10000 )
236+ let delay : number
237+ if ( isRateLimited ) {
238+ // Use Retry-After header if available, otherwise longer backoff for 429s
239+ const retryAfter = this . parseRetryAfter ( axiosErr ?. response ?. headers )
240+ delay =
241+ retryAfter ??
242+ Math . min ( initialDelayMs * Math . pow ( 2 , attempt + 1 ) , 30000 )
243+ } else {
244+ delay = Math . min ( initialDelayMs * Math . pow ( 2 , attempt ) , 10000 )
245+ }
141246 const jitter = Math . floor ( delay * 0.25 * ( Math . random ( ) * 2 - 1 ) )
142247 const sleepMs = Math . max ( 250 , delay + jitter )
143248 console . warn (
144249 `GET retry ${ attempt + 1 } /${ retries } for ${ url } after error (${
145250 code || status
146- } ): ${ err ?. message || 'unknown' } - waiting ${ sleepMs } ms`
251+ } ): ${ axiosErr ?. message || 'unknown' } - waiting ${ sleepMs } ms`
147252 )
148253 await new Promise ( ( res ) => setTimeout ( res , sleepMs ) )
149254 attempt ++
150255 }
151256 }
152- throw lastError
257+ throw lastError as Error
258+ }
259+
260+ /**
261+ * Parse the Retry-After header value into milliseconds
262+ * Supports both seconds (integer) and HTTP-date formats
263+ */
264+ private parseRetryAfter ( headers ?: Record < string , unknown > ) : number | null {
265+ const retryAfter = headers ?. [ 'retry-after' ]
266+ if ( ! retryAfter || typeof retryAfter !== 'string' ) return null
267+
268+ const seconds = parseInt ( retryAfter , 10 )
269+ if ( ! isNaN ( seconds ) ) {
270+ return seconds * 1000
271+ }
272+
273+ // Try parsing as HTTP-date
274+ const date = new Date ( retryAfter )
275+ if ( ! isNaN ( date . getTime ( ) ) ) {
276+ return Math . max ( 0 , date . getTime ( ) - Date . now ( ) )
277+ }
278+
279+ return null
153280 }
154281
155282 /**
156283 * "Abstact" function each ApiBot must implement
157284 * Parses endpoint response
158285 * @param {* } responseData - Dict parsed from API request json
159286 */
160- async handleAPIResponse ( responseData : any ) {
287+ async handleAPIResponse ( responseData : unknown ) {
161288 console . warn ( 'handleAPIResponse function not implemented!' , responseData )
162289 }
163290
164291 /**
165- * "Abstact " function each ApiBot must implement
292+ * "Abstract " function each ApiBot must implement
166293 * Builds and sends any Discord messages
167- * @param { * } msg - Event info dict
294+ * @param msg - Event info dict
168295 */
169- async buildDiscordMessage ( msg : any ) {
296+ async buildDiscordMessage ( msg : unknown ) {
170297 console . warn ( 'buildDiscordMessage function not implemented!' , msg )
171298 }
172299
0 commit comments