Skip to content

Commit 1a163f4

Browse files
tons of artbot fixes (#740)
* fixing some artbot rate limiting issues * better error handling, cleanup, dedup, memory leak fixes
1 parent ba51eb2 commit 1a163f4

17 files changed

+1206
-3159
lines changed

codegen.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const config: CodegenConfig = {
44
overwrite: true,
55
schema: 'https://data.artblocks.io/v1/graphql',
66
documents: ['./src/Data/**/*.graphql'],
7+
78
generates: {
89
'generated/graphql.ts': {
910
plugins: [

package.json

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,45 +28,25 @@
2828
"license": "MIT",
2929
"dependencies": {
3030
"@ardatan/aggregate-error": "^0.0.6",
31-
"@graphql-codegen/cli": "^2.13.11",
32-
"@graphql-codegen/typed-document-node": "^2.3.6",
33-
"@graphql-codegen/typescript": "^2.8.1",
34-
"@graphql-codegen/typescript-operations": "^2.5.5",
35-
"@graphql-codegen/typescript-resolvers": "2.7.6",
36-
"@graphql-codegen/typescript-urql": "^3.7.3",
37-
"@graphql-codegen/urql-introspection": "^2.2.1",
3831
"@graphql-tools/utils": "^8.3.0",
3932
"@opensea/stream-js": "^0.2.1",
4033
"@supabase/supabase-js": "^2.29.0",
41-
"@types/node": "20.1.2",
42-
"@types/node-cron": "^3.0.8",
43-
"@typescript-eslint/eslint-plugin": "^5.41.0",
44-
"@typescript-eslint/parser": "^5.41.0",
4534
"@urql/exchange-retry": "^1.0.0",
4635
"axios": "^1.6.0",
4736
"body-parser": "^1.15.2",
48-
"chatgpt": "^5.2.5",
4937
"croner": "^6.0.7",
5038
"discord.js": "^14.16.3",
5139
"dotenv": "^16.0.3",
52-
"eslint": "^8.26.0",
53-
"eslint-config-airbnb-base": "^15.0.0",
54-
"eslint-config-prettier": "^8.5.0",
55-
"eslint-plugin-import": "^2.26.0",
56-
"eslint-plugin-prettier": "^4.0.0",
40+
"express": "^4.18.2",
5741
"googleapis": "^92.0.0",
5842
"graphql": "^16.8.1",
5943
"graphql-tag": "^2.12.6",
60-
"husky": "^8.0.3",
61-
"jest": "^28.0.3",
6244
"lodash.deburr": "^4.1.0",
6345
"ms": "^2.0.0",
6446
"node-fetch": "^2.6.1",
6547
"node-html-parser": "^3.2.0",
6648
"node-localstorage": "^3.0.5",
6749
"openai": "^4.73.0",
68-
"prettier": "^2.6.2",
69-
"react": "18.2.0",
7050
"reconnecting-websocket": "^4.4.0",
7151
"sharp": "^0.32.6",
7252
"timeago.js": "^4.0.2",
@@ -75,11 +55,29 @@
7555
"typescript": "^5.1.6",
7656
"urql": "^3.0.3",
7757
"viem": "^2.38.3",
78-
"web3": "^1.7.0",
7958
"ws": "^8.18.3"
8059
},
8160
"devDependencies": {
61+
"@graphql-codegen/cli": "^2.13.11",
62+
"@graphql-codegen/typed-document-node": "^2.3.6",
63+
"@graphql-codegen/typescript": "^2.8.1",
64+
"@graphql-codegen/typescript-operations": "^2.5.5",
65+
"@graphql-codegen/typescript-resolvers": "^2.7.6",
66+
"@graphql-codegen/typescript-urql": "^3.7.3",
67+
"@graphql-codegen/urql-introspection": "^2.2.1",
68+
"@types/node": "20.1.2",
69+
"@types/node-cron": "^3.0.8",
8270
"@types/node-localstorage": "^1.3.3",
83-
"@types/phoenix": "^1.6.6"
71+
"@types/phoenix": "^1.6.6",
72+
"@typescript-eslint/eslint-plugin": "^5.41.0",
73+
"@typescript-eslint/parser": "^5.41.0",
74+
"eslint": "^8.26.0",
75+
"eslint-config-airbnb-base": "^15.0.0",
76+
"eslint-config-prettier": "^8.5.0",
77+
"eslint-plugin-import": "^2.26.0",
78+
"eslint-plugin-prettier": "^4.0.0",
79+
"husky": "^8.0.3",
80+
"jest": "^28.0.3",
81+
"prettier": "^2.6.2"
8482
}
8583
}

src/Classes/APIBots/ApiPollBot.ts

Lines changed: 147 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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 */
1111
export 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' || /timeout/i.test(err?.message || '')
129-
const isReset = err?.code === 'ECONNRESET'
130-
const status = err?.response?.status
221+
axiosErr?.code === 'ECONNABORTED' ||
222+
/timeout/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

src/Classes/APIBots/OpenSeaEventsPollBot.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface OpenSeaEventResponse {
1818
export interface OpenSeaEvent {
1919
event_type: 'order' | 'sale' | 'transfer' | 'cancel' | 'redemption'
2020
order_hash?: string
21-
order_type?: any
21+
order_type?: string
2222
protocol_address?: string
2323
start_date?: number
2424
expiration_date?: number
@@ -78,6 +78,7 @@ export interface OpenSeaCriteria {
7878

7979
const EVENT_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
8080
const MAX_PAGINATION_PAGES = 30 // Safety limit to prevent infinite pagination loops
81+
const PAGINATION_DELAY_MS = 1500 // Delay between paginated requests to avoid 429s
8182

8283
/** Single API Poller for OpenSea Events API - handles SALES ONLY for backfilling missed stream events */
8384
export class OpenSeaEventsPollBot extends APIPollBot {
@@ -99,7 +100,7 @@ export class OpenSeaEventsPollBot extends APIPollBot {
99100
apiEndpoint: string,
100101
refreshRateMs: number,
101102
bot: Client,
102-
headers: any,
103+
headers: Record<string, string | undefined>,
103104
twitterBot: TwitterBot | undefined,
104105
trackedContracts: string[] = [],
105106
saleBot: OpenSeaSaleBot
@@ -236,6 +237,8 @@ export class OpenSeaEventsPollBot extends APIPollBot {
236237
return
237238
} else {
238239
try {
240+
// Delay between pages to stay under OpenSea rate limits
241+
await new Promise((res) => setTimeout(res, PAGINATION_DELAY_MS))
239242
const nextPageResponse = await this.fetchNextPage(responseData.next)
240243
await this.processEventPage(
241244
nextPageResponse,

0 commit comments

Comments
 (0)