(url, options = {})
| 217 | const rateLimitState = new Map(); |
| 218 | |
| 219 | async function rateLimitedFetch(url, options = {}) { |
| 220 | const settings = await getSettings(); |
| 221 | const domain = new URL(url).hostname; |
| 222 | |
| 223 | if (!rateLimitState.has(domain)) { |
| 224 | rateLimitState.set(domain, { delay: settings.baseDelay, lastRequest: 0 }); |
| 225 | } |
| 226 | |
| 227 | const state = rateLimitState.get(domain); |
| 228 | const now = Date.now(); |
| 229 | const timeSince = now - state.lastRequest; |
| 230 | |
| 231 | if (timeSince < state.delay) { |
| 232 | await new Promise(r => setTimeout(r, state.delay - timeSince)); |
| 233 | } |
| 234 | |
| 235 | state.lastRequest = Date.now(); |
| 236 | |
| 237 | try { |
| 238 | const response = await fetch(url, { ...options, signal: AbortSignal.timeout(15000) }); |
| 239 | |
| 240 | if ([429, 503, 502].includes(response.status)) { |
| 241 | state.delay = Math.min(state.delay * 2, 10000); |
| 242 | const retryAfter = response.headers.get('Retry-After'); |
| 243 | if (retryAfter) { |
| 244 | await new Promise(r => setTimeout(r, parseInt(retryAfter) * 1000)); |
| 245 | } |
| 246 | throw new Error(`Rate limited: ${response.status}`); |
| 247 | } |
| 248 | |
| 249 | // Recover delay on success |
| 250 | state.delay = Math.max(state.delay * 0.9, settings.baseDelay); |
| 251 | return response; |
| 252 | } catch (error) { |
| 253 | if (error.name === 'TimeoutError') { |
| 254 | state.delay = Math.min(state.delay * 1.5, 10000); |
| 255 | } |
| 256 | throw error; |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // ============================================================================ |
| 261 | // CONTENT FILTERING & COMPARISON |
no test coverage detected