scorchcrawl-mcp 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,361 +0,0 @@
1
- /**
2
- * Rate Limiting, Concurrency Control & Quota Monitoring
3
- *
4
- * The Copilot SDK / CLI engine handles HTTP-level rate limiting automatically:
5
- * - 429 retries (up to 5) with exponential backoff + jitter
6
- * - `retry-after` header respect
7
- * - HTTP/2 GOAWAY recovery
8
- * - 402 quota-exceeded errors surfaced as session.error
9
- *
10
- * This module adds the APPLICATION-LEVEL safeguards that the SDK doesn't:
11
- * 1. Per-user and global concurrency limits for agent jobs
12
- * 2. Quota snapshot tracking with proactive rejection
13
- * 3. Sliding-window request rate tracking to avoid hitting 429 at all
14
- * 4. Stale job garbage collection
15
- * 5. Error classification and hook configuration
16
- */
17
- export function buildRateLimitConfig() {
18
- return {
19
- maxGlobalConcurrency: envInt('RATE_LIMIT_MAX_GLOBAL_CONCURRENCY', 10),
20
- maxPerUserConcurrency: envInt('RATE_LIMIT_MAX_PER_USER_CONCURRENCY', 3),
21
- rateLimitWindowMs: envInt('RATE_LIMIT_WINDOW_MS', 60_000),
22
- maxRequestsPerWindow: envInt('RATE_LIMIT_MAX_REQUESTS_PER_WINDOW', 20),
23
- maxGlobalRequestsPerWindow: envInt('RATE_LIMIT_MAX_GLOBAL_REQUESTS_PER_WINDOW', 60),
24
- quotaRejectThresholdPercent: envInt('RATE_LIMIT_QUOTA_REJECT_PERCENT', 5),
25
- staleJobTimeoutMs: envInt('RATE_LIMIT_STALE_JOB_TIMEOUT_MS', 10 * 60 * 1000),
26
- gcIntervalMs: envInt('RATE_LIMIT_GC_INTERVAL_MS', 60_000),
27
- };
28
- }
29
- function envInt(key, fallback) {
30
- const v = process.env[key];
31
- if (v == null)
32
- return fallback;
33
- const n = parseInt(v, 10);
34
- return Number.isFinite(n) ? n : fallback;
35
- }
36
- // ---------------------------------------------------------------------------
37
- // ConcurrencyTracker
38
- // ---------------------------------------------------------------------------
39
- /**
40
- * Tracks in-flight agent jobs both globally and per-user.
41
- * Call `acquire()` before starting a job, `release()` when done.
42
- */
43
- export class ConcurrencyTracker {
44
- config;
45
- globalActive = 0;
46
- perUser = new Map();
47
- constructor(config) {
48
- this.config = config;
49
- }
50
- /** Check if a new job is allowed for the given user key */
51
- canAcquire(userKey) {
52
- if (this.globalActive >= this.config.maxGlobalConcurrency) {
53
- return {
54
- allowed: false,
55
- reason: `Server is at maximum capacity (${this.config.maxGlobalConcurrency} concurrent jobs). Please retry shortly.`,
56
- retryAfterSeconds: 10,
57
- };
58
- }
59
- const userCount = this.perUser.get(userKey) || 0;
60
- if (userCount >= this.config.maxPerUserConcurrency) {
61
- return {
62
- allowed: false,
63
- reason: `You already have ${userCount} concurrent agent jobs (max ${this.config.maxPerUserConcurrency}). Wait for a job to complete before starting another.`,
64
- retryAfterSeconds: 15,
65
- };
66
- }
67
- return { allowed: true };
68
- }
69
- acquire(userKey) {
70
- this.globalActive++;
71
- this.perUser.set(userKey, (this.perUser.get(userKey) || 0) + 1);
72
- }
73
- release(userKey) {
74
- this.globalActive = Math.max(0, this.globalActive - 1);
75
- const cur = this.perUser.get(userKey) || 0;
76
- if (cur <= 1) {
77
- this.perUser.delete(userKey);
78
- }
79
- else {
80
- this.perUser.set(userKey, cur - 1);
81
- }
82
- }
83
- /** Current stats (for observability / health endpoint) */
84
- stats() {
85
- return {
86
- global: this.globalActive,
87
- perUser: Object.fromEntries(this.perUser),
88
- };
89
- }
90
- }
91
- // ---------------------------------------------------------------------------
92
- // SlidingWindowRateLimiter
93
- // ---------------------------------------------------------------------------
94
- /**
95
- * Sliding-window rate limiter that tracks request timestamps.
96
- * This prevents us from *sending* too many requests in the first place,
97
- * so we rarely hit the API-level 429.
98
- */
99
- export class SlidingWindowRateLimiter {
100
- config;
101
- /** Map<userKey, timestamp[]> */
102
- perUser = new Map();
103
- globalTimestamps = [];
104
- constructor(config) {
105
- this.config = config;
106
- }
107
- check(userKey) {
108
- const now = Date.now();
109
- const windowStart = now - this.config.rateLimitWindowMs;
110
- // --- Global check ---
111
- this.globalTimestamps = this.globalTimestamps.filter((t) => t > windowStart);
112
- if (this.globalTimestamps.length >= this.config.maxGlobalRequestsPerWindow) {
113
- const oldest = this.globalTimestamps[0];
114
- const retryAfter = Math.ceil((oldest + this.config.rateLimitWindowMs - now) / 1000);
115
- return {
116
- allowed: false,
117
- reason: `Global rate limit reached (${this.config.maxGlobalRequestsPerWindow} requests per ${this.config.rateLimitWindowMs / 1000}s window). Please wait.`,
118
- retryAfterSeconds: Math.max(1, retryAfter),
119
- };
120
- }
121
- // --- Per-user check ---
122
- let userTs = this.perUser.get(userKey) || [];
123
- userTs = userTs.filter((t) => t > windowStart);
124
- this.perUser.set(userKey, userTs);
125
- if (userTs.length >= this.config.maxRequestsPerWindow) {
126
- const oldest = userTs[0];
127
- const retryAfter = Math.ceil((oldest + this.config.rateLimitWindowMs - now) / 1000);
128
- return {
129
- allowed: false,
130
- reason: `Rate limit reached (${this.config.maxRequestsPerWindow} requests per ${this.config.rateLimitWindowMs / 1000}s window). Please wait.`,
131
- retryAfterSeconds: Math.max(1, retryAfter),
132
- };
133
- }
134
- return { allowed: true };
135
- }
136
- /** Record that a request was made */
137
- record(userKey) {
138
- const now = Date.now();
139
- this.globalTimestamps.push(now);
140
- const userTs = this.perUser.get(userKey) || [];
141
- userTs.push(now);
142
- this.perUser.set(userKey, userTs);
143
- }
144
- /** Purge old timestamps (called by GC) */
145
- gc() {
146
- const cutoff = Date.now() - this.config.rateLimitWindowMs;
147
- this.globalTimestamps = this.globalTimestamps.filter((t) => t > cutoff);
148
- for (const [key, ts] of this.perUser) {
149
- const filtered = ts.filter((t) => t > cutoff);
150
- if (filtered.length === 0) {
151
- this.perUser.delete(key);
152
- }
153
- else {
154
- this.perUser.set(key, filtered);
155
- }
156
- }
157
- }
158
- }
159
- // ---------------------------------------------------------------------------
160
- // QuotaMonitor
161
- // ---------------------------------------------------------------------------
162
- /**
163
- * Tracks Copilot quota snapshots per user to proactively reject requests
164
- * when quota is about to be exhausted.
165
- *
166
- * Quota data is fed from Copilot session `assistant.usage` events.
167
- */
168
- export class QuotaMonitor {
169
- config;
170
- perUser = new Map();
171
- constructor(config) {
172
- this.config = config;
173
- }
174
- /** Update quota snapshot for a user (call from session event handler) */
175
- update(userKey, snapshot) {
176
- const existing = this.perUser.get(userKey);
177
- this.perUser.set(userKey, {
178
- remainingPercent: snapshot.remainingPercent ?? existing?.remainingPercent ?? 100,
179
- usedRequests: snapshot.usedRequests ?? existing?.usedRequests ?? 0,
180
- entitlementRequests: snapshot.entitlementRequests ?? existing?.entitlementRequests ?? -1,
181
- isUnlimited: snapshot.isUnlimited ?? existing?.isUnlimited ?? false,
182
- resetDate: snapshot.resetDate ?? existing?.resetDate,
183
- lastUpdated: Date.now(),
184
- });
185
- }
186
- /** Check if the user has sufficient quota to start a new agent job */
187
- check(userKey) {
188
- const info = this.perUser.get(userKey);
189
- // No quota info yet → allow (first request, or info not yet retrieved)
190
- if (!info)
191
- return { allowed: true };
192
- // Unlimited entitlement → always allow
193
- if (info.isUnlimited)
194
- return { allowed: true };
195
- // Stale quota info (> 5 min old) → allow but log warning
196
- if (Date.now() - info.lastUpdated > 5 * 60 * 1000) {
197
- return { allowed: true };
198
- }
199
- if (info.remainingPercent <= this.config.quotaRejectThresholdPercent) {
200
- const resetMsg = info.resetDate
201
- ? ` Quota resets on ${info.resetDate}.`
202
- : '';
203
- return {
204
- allowed: false,
205
- reason: `Copilot quota nearly exhausted (${info.remainingPercent.toFixed(1)}% remaining, ${info.usedRequests}/${info.entitlementRequests} used).${resetMsg} Upgrade your plan or wait for reset.`,
206
- };
207
- }
208
- return { allowed: true };
209
- }
210
- /** Get quota info for a user (for diagnostics) */
211
- get(userKey) {
212
- return this.perUser.get(userKey);
213
- }
214
- /** Purge old entries (called by GC) */
215
- gc() {
216
- const cutoff = Date.now() - 30 * 60 * 1000; // 30 min
217
- for (const [key, info] of this.perUser) {
218
- if (info.lastUpdated < cutoff) {
219
- this.perUser.delete(key);
220
- }
221
- }
222
- }
223
- }
224
- // ---------------------------------------------------------------------------
225
- // Unified RateLimitGuard
226
- // ---------------------------------------------------------------------------
227
- /**
228
- * Facade that combines concurrency tracking, sliding-window rate limiting,
229
- * and quota monitoring into a single `guard.check()` call.
230
- */
231
- export class RateLimitGuard {
232
- config;
233
- concurrency;
234
- rateLimit;
235
- quota;
236
- gcTimer = null;
237
- constructor(config) {
238
- this.config = config || buildRateLimitConfig();
239
- this.concurrency = new ConcurrencyTracker(this.config);
240
- this.rateLimit = new SlidingWindowRateLimiter(this.config);
241
- this.quota = new QuotaMonitor(this.config);
242
- // Start periodic GC
243
- this.gcTimer = setInterval(() => {
244
- this.rateLimit.gc();
245
- this.quota.gc();
246
- }, this.config.gcIntervalMs);
247
- // Don't block process exit
248
- if (this.gcTimer.unref)
249
- this.gcTimer.unref();
250
- }
251
- /**
252
- * Check ALL rate-limit gates in priority order.
253
- * Returns the first rejection, or { allowed: true } if all pass.
254
- */
255
- check(userKey) {
256
- // 1. Concurrency (cheapest check)
257
- const concResult = this.concurrency.canAcquire(userKey);
258
- if (!concResult.allowed)
259
- return concResult;
260
- // 2. Sliding-window rate limit
261
- const rateResult = this.rateLimit.check(userKey);
262
- if (!rateResult.allowed)
263
- return rateResult;
264
- // 3. Quota (proactive rejection)
265
- const quotaResult = this.quota.check(userKey);
266
- if (!quotaResult.allowed)
267
- return quotaResult;
268
- return { allowed: true };
269
- }
270
- /** Record that a request was admitted and is now in-flight */
271
- acquire(userKey) {
272
- this.concurrency.acquire(userKey);
273
- this.rateLimit.record(userKey);
274
- }
275
- /** Release concurrency slot when job finishes */
276
- release(userKey) {
277
- this.concurrency.release(userKey);
278
- }
279
- /** Stats for observability */
280
- stats() {
281
- return {
282
- concurrency: this.concurrency.stats(),
283
- config: {
284
- maxGlobalConcurrency: this.config.maxGlobalConcurrency,
285
- maxPerUserConcurrency: this.config.maxPerUserConcurrency,
286
- maxRequestsPerWindow: this.config.maxRequestsPerWindow,
287
- rateLimitWindowMs: this.config.rateLimitWindowMs,
288
- quotaRejectThresholdPercent: this.config.quotaRejectThresholdPercent,
289
- },
290
- };
291
- }
292
- /** Clean shutdown */
293
- shutdown() {
294
- if (this.gcTimer) {
295
- clearInterval(this.gcTimer);
296
- this.gcTimer = null;
297
- }
298
- }
299
- }
300
- // ---------------------------------------------------------------------------
301
- // Copilot SDK session error hook factory
302
- // ---------------------------------------------------------------------------
303
- /**
304
- * Build an `onErrorOccurred` hook for Copilot SDK sessions.
305
- *
306
- * This integrates with the SDK's ErrorOccurredHookOutput to make
307
- * intelligent retry/skip/abort decisions:
308
- * - model_call + recoverable → retry (up to 2 extra)
309
- * - tool_execution → skip the failed tool, continue agent
310
- * - quota / auth errors → abort immediately
311
- */
312
- export function buildErrorHook(jobId, logger) {
313
- return (input) => {
314
- const log = logger || console;
315
- log.warn(`[RateLimit] Agent ${jobId} error`, {
316
- context: input.errorContext,
317
- recoverable: input.recoverable,
318
- error: input.error.substring(0, 200),
319
- });
320
- // Quota / auth errors → abort immediately, don't waste retries
321
- const lowerErr = input.error.toLowerCase();
322
- if (lowerErr.includes('quota') ||
323
- lowerErr.includes('402') ||
324
- lowerErr.includes('not licensed') ||
325
- lowerErr.includes('authentication')) {
326
- return { errorHandling: 'abort', suppressOutput: false };
327
- }
328
- // Rate limit errors after SDK exhausted its 5 retries → abort
329
- if (lowerErr.includes('rate limit') || lowerErr.includes('429')) {
330
- return {
331
- errorHandling: 'abort',
332
- suppressOutput: false,
333
- userNotification: 'Rate limit reached. Please retry later.',
334
- };
335
- }
336
- // Model call errors that are recoverable → retry up to 2 additional times
337
- if (input.errorContext === 'model_call' && input.recoverable) {
338
- return { errorHandling: 'retry', retryCount: 2 };
339
- }
340
- // Tool execution errors → skip the failed tool, let agent continue
341
- if (input.errorContext === 'tool_execution') {
342
- return { errorHandling: 'skip' };
343
- }
344
- // Everything else → abort
345
- return { errorHandling: 'abort' };
346
- };
347
- }
348
- /**
349
- * Identify agent jobs that have been "processing" longer than the timeout.
350
- * The caller is responsible for actually marking/removing them.
351
- */
352
- export function findStaleJobs(jobs, timeoutMs) {
353
- const now = Date.now();
354
- const stale = [];
355
- for (const job of jobs) {
356
- if (job.status === 'processing' && now - job.createdAt > timeoutMs) {
357
- stale.push(job.id);
358
- }
359
- }
360
- return stale;
361
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * Scrape Response Cache
3
- *
4
- * Avoids redundant fetches when the same URL+params combination
5
- * is requested multiple times within a short window. This is common
6
- * when an LLM agent retries or when multiple tools scrape the same page.
7
- */
8
- import { createHash } from 'node:crypto';
9
- const SCRAPE_CACHE_TTL_MS = parseInt(process.env.SCORCHCRAWL_SCRAPE_CACHE_TTL_MS || '120000', 10); // 2 min default
10
- const SCRAPE_CACHE_MAX = parseInt(process.env.SCORCHCRAWL_SCRAPE_CACHE_MAX || '50', 10);
11
- export const scrapeCache = new Map();
12
- export function scrapeCacheKey(url, options) {
13
- const fingerprint = JSON.stringify({ url, ...options });
14
- return createHash('sha256').update(fingerprint).digest('hex');
15
- }
16
- export function getCachedScrape(key) {
17
- const entry = scrapeCache.get(key);
18
- if (!entry)
19
- return null;
20
- if (Date.now() - entry.timestamp > SCRAPE_CACHE_TTL_MS) {
21
- scrapeCache.delete(key);
22
- return null;
23
- }
24
- return entry.response;
25
- }
26
- export function setCachedScrape(key, response) {
27
- scrapeCache.set(key, { response, timestamp: Date.now() });
28
- // Evict oldest if over limit
29
- if (scrapeCache.size > SCRAPE_CACHE_MAX) {
30
- const oldest = scrapeCache.keys().next().value;
31
- if (oldest)
32
- scrapeCache.delete(oldest);
33
- }
34
- }