@littlebearapps/platform-consumer-sdk 1.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.
@@ -0,0 +1,447 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ /**
4
+ * Circuit Breaker Middleware
5
+ *
6
+ * Project-level circuit breaker middleware for Cloudflare Workers.
7
+ * Extracted from platform/main with multi-account improvements.
8
+ *
9
+ * Two-tier circuit breaker system:
10
+ * - Feature-level (SDK core): `CIRCUIT_STATUS` (GO/STOP) — per-feature budget enforcement
11
+ * - Project-level (this module): `PROJECT_CB_STATUS` (active/warning/paused) — request-level gating
12
+ *
13
+ * Status levels:
14
+ * - 'active' (CLOSED): Normal operation, all requests pass through
15
+ * - 'warning' (WARNING): Soft limit exceeded, requests pass but with logging
16
+ * - 'paused' (OPEN): Hard limit exceeded (1.5x soft), requests blocked with 503
17
+ *
18
+ * @example Simple check
19
+ * ```typescript
20
+ * import { checkProjectCircuitBreaker, CB_PROJECT_KEYS } from '@littlebearapps/platform-consumer-sdk/middleware';
21
+ *
22
+ * const cbResponse = await checkProjectCircuitBreaker(CB_PROJECT_KEYS.SCOUT, env.PLATFORM_CACHE);
23
+ * if (cbResponse) return cbResponse;
24
+ * ```
25
+ *
26
+ * @example Hono middleware
27
+ * ```typescript
28
+ * import { createCircuitBreakerMiddleware, CB_PROJECT_KEYS } from '@littlebearapps/platform-consumer-sdk/middleware';
29
+ *
30
+ * const app = new Hono<{ Bindings: Env }>();
31
+ * app.use('*', createCircuitBreakerMiddleware(CB_PROJECT_KEYS.SCOUT));
32
+ * // Brand Copilot: skip OAuth paths
33
+ * app.use('*', createCircuitBreakerMiddleware(CB_PROJECT_KEYS.BRAND_COPILOT, {
34
+ * skipPaths: ['/health', '/healthz', '/_health', '/.well-known/', '/oauth/'],
35
+ * }));
36
+ * ```
37
+ */
38
+
39
+ import { createLogger, type Logger } from './logging';
40
+
41
+ // =============================================================================
42
+ // MODULE LOGGER (lazy-initialised to avoid global scope crypto calls)
43
+ // =============================================================================
44
+
45
+ let _log: Logger | null = null;
46
+ function getLog(): Logger {
47
+ if (!_log) {
48
+ _log = createLogger({
49
+ worker: 'platform-sdk',
50
+ featureId: 'platform:sdk:circuit-breaker',
51
+ });
52
+ }
53
+ return _log;
54
+ }
55
+
56
+ // =============================================================================
57
+ // CONSTANTS
58
+ // =============================================================================
59
+
60
+ /**
61
+ * Project-level circuit breaker status values.
62
+ * Distinct from CIRCUIT_STATUS (GO/STOP) which is feature-level.
63
+ *
64
+ * | Layer | Constant | KV Key Pattern | Values | Purpose |
65
+ * |-------|----------|---------------|--------|---------|
66
+ * | Feature-level | CIRCUIT_STATUS | CONFIG:FEATURE:{id}:STATUS | GO / STOP | Per-feature budget |
67
+ * | Project-level | PROJECT_CB_STATUS | PROJECT:{SLUG}:STATUS | active / warning / paused | Request gating |
68
+ */
69
+ export const PROJECT_CB_STATUS = {
70
+ /** Normal operation — all requests pass through */
71
+ CLOSED: 'active',
72
+ /** Soft limit exceeded — requests pass with warnings logged */
73
+ WARNING: 'warning',
74
+ /** Hard limit exceeded — requests blocked with 503 */
75
+ OPEN: 'paused',
76
+ } as const;
77
+
78
+ export type CircuitBreakerStatusValue = (typeof PROJECT_CB_STATUS)[keyof typeof PROJECT_CB_STATUS];
79
+
80
+ /** KV key for global stop — affects ALL services (manual emergency stop) */
81
+ export const GLOBAL_STOP_KEY = 'GLOBAL_STOP_ALL';
82
+
83
+ /**
84
+ * Known project circuit breaker KV keys.
85
+ * Use createProjectKey() for custom/new projects.
86
+ */
87
+ export const CB_PROJECT_KEYS = {
88
+ /** Global stop — affects ALL services (manual emergency stop) */
89
+ GLOBAL_STOP: GLOBAL_STOP_KEY,
90
+ /** Scout worker status */
91
+ SCOUT: 'PROJECT:SCOUT:STATUS',
92
+ /** Brand Copilot worker status */
93
+ BRAND_COPILOT: 'PROJECT:BRAND-COPILOT:STATUS',
94
+ /** Australian History MCP (semantic-librarian) worker status */
95
+ AUSTRALIAN_HISTORY_MCP: 'PROJECT:AUSTRALIAN-HISTORY-MCP:STATUS',
96
+ /** Platform worker status (self-monitoring) */
97
+ PLATFORM: 'PROJECT:PLATFORM:STATUS',
98
+ } as const;
99
+
100
+ /** Circuit breaker response codes */
101
+ export const CB_ERROR_CODES = {
102
+ GLOBAL: 'GLOBAL_CIRCUIT_BREAKER',
103
+ PROJECT: 'PROJECT_CIRCUIT_BREAKER',
104
+ WARNING: 'BUDGET_WARNING',
105
+ } as const;
106
+
107
+ /** Response header for budget status visibility */
108
+ export const BUDGET_STATUS_HEADER = 'X-Platform-Budget';
109
+
110
+ /** Default paths to skip in circuit breaker middleware (health endpoints) */
111
+ const DEFAULT_SKIP_PATHS = ['/health', '/healthz', '/_health'];
112
+
113
+ // =============================================================================
114
+ // TYPES
115
+ // =============================================================================
116
+
117
+ export interface CircuitBreakerErrorResponse {
118
+ error: string;
119
+ code: string;
120
+ retryAfterSeconds: number;
121
+ }
122
+
123
+ /** Result of circuit breaker check with detailed status information */
124
+ export interface CircuitBreakerCheckResult {
125
+ /** Whether the request should be allowed */
126
+ allowed: boolean;
127
+ /** Current status: 'active' | 'warning' | 'paused' | 'global_stop' */
128
+ status: CircuitBreakerStatusValue | 'global_stop';
129
+ /** Project ID extracted from key */
130
+ projectId: string;
131
+ /** Response to return if blocked (null if allowed) */
132
+ response: Response | null;
133
+ }
134
+
135
+ /** Options for createCircuitBreakerMiddleware */
136
+ export interface CircuitBreakerMiddlewareOptions {
137
+ /**
138
+ * Paths to skip circuit breaker checks (allows monitoring during circuit break).
139
+ * Checks if request path starts with any of these values.
140
+ * @default ['/health', '/healthz', '/_health']
141
+ */
142
+ skipPaths?: string[];
143
+ }
144
+
145
+ // =============================================================================
146
+ // KEY GENERATION
147
+ // =============================================================================
148
+
149
+ /**
150
+ * Generate a PROJECT:{SLUG}:STATUS key from a project slug.
151
+ * Use this for custom/new projects not in CB_PROJECT_KEYS.
152
+ *
153
+ * @param slug - Project slug (will be uppercased)
154
+ * @returns KV key in format PROJECT:{SLUG}:STATUS
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * const key = createProjectKey('my-project');
159
+ * // 'PROJECT:MY-PROJECT:STATUS'
160
+ * ```
161
+ */
162
+ export function createProjectKey(slug: string): string {
163
+ return `PROJECT:${slug.toUpperCase()}:STATUS`;
164
+ }
165
+
166
+ /**
167
+ * Extract project ID from KV key for logging.
168
+ */
169
+ function extractProjectId(projectKey: string): string {
170
+ const match = projectKey.match(/PROJECT:([^:]+):STATUS/);
171
+ return match ? match[1].toLowerCase() : 'unknown';
172
+ }
173
+
174
+ // =============================================================================
175
+ // CIRCUIT BREAKER CHECKS
176
+ // =============================================================================
177
+
178
+ /**
179
+ * Check circuit breaker status and return detailed result.
180
+ *
181
+ * @param projectKey - The KV key for the project status (use CB_PROJECT_KEYS or createProjectKey)
182
+ * @param kv - KV namespace binding (PLATFORM_CACHE)
183
+ * @returns Detailed check result with status, projectId, and response if blocked
184
+ */
185
+ export async function checkProjectCircuitBreakerDetailed(
186
+ projectKey: string,
187
+ kv: KVNamespace
188
+ ): Promise<CircuitBreakerCheckResult> {
189
+ const projectId = extractProjectId(projectKey);
190
+
191
+ // 1. Check global stop first (affects all services)
192
+ const globalStop = await kv.get(GLOBAL_STOP_KEY);
193
+ if (globalStop === 'true') {
194
+ return {
195
+ allowed: false,
196
+ status: 'global_stop',
197
+ projectId,
198
+ response: createCircuitBreakerResponse({
199
+ error: 'Service temporarily unavailable due to global circuit breaker',
200
+ code: CB_ERROR_CODES.GLOBAL,
201
+ retryAfterSeconds: 3600,
202
+ }),
203
+ };
204
+ }
205
+
206
+ // 2. Check project-specific status
207
+ const projectStatus = (await kv.get(projectKey)) as CircuitBreakerStatusValue | null;
208
+
209
+ // OPEN (paused): Hard limit exceeded — block request
210
+ if (projectStatus === PROJECT_CB_STATUS.OPEN) {
211
+ return {
212
+ allowed: false,
213
+ status: PROJECT_CB_STATUS.OPEN,
214
+ projectId,
215
+ response: createCircuitBreakerResponse({
216
+ error: 'Service paused due to resource limits exceeded',
217
+ code: CB_ERROR_CODES.PROJECT,
218
+ retryAfterSeconds: 1800,
219
+ }),
220
+ };
221
+ }
222
+
223
+ // WARNING: Soft limit exceeded — allow with logging
224
+ if (projectStatus === PROJECT_CB_STATUS.WARNING) {
225
+ getLog().warn('Request allowed despite budget warning', undefined, {
226
+ type: 'budget_exceeded',
227
+ project: projectId,
228
+ status: 'warning',
229
+ });
230
+
231
+ return {
232
+ allowed: true,
233
+ status: PROJECT_CB_STATUS.WARNING,
234
+ projectId,
235
+ response: null,
236
+ };
237
+ }
238
+
239
+ // CLOSED (active or null): Normal operation
240
+ return {
241
+ allowed: true,
242
+ status: PROJECT_CB_STATUS.CLOSED,
243
+ projectId,
244
+ response: null,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Simple circuit breaker check — returns a Response to block, or null to proceed.
250
+ *
251
+ * @param projectKey - The KV key for the project status (use CB_PROJECT_KEYS or createProjectKey)
252
+ * @param kv - KV namespace binding (PLATFORM_CACHE)
253
+ * @returns Response if circuit is tripped (return immediately), null if OK to proceed
254
+ */
255
+ export async function checkProjectCircuitBreaker(
256
+ projectKey: string,
257
+ kv: KVNamespace
258
+ ): Promise<Response | null> {
259
+ const result = await checkProjectCircuitBreakerDetailed(projectKey, kv);
260
+ return result.response;
261
+ }
262
+
263
+ // =============================================================================
264
+ // STATUS QUERIES
265
+ // =============================================================================
266
+
267
+ /**
268
+ * Get the circuit breaker status for a single project.
269
+ * Primary API for per-account use in multi-account deployments.
270
+ *
271
+ * @param kv - KV namespace
272
+ * @param projectKey - PROJECT:{SLUG}:STATUS key
273
+ * @returns Current status value or null if not set
274
+ */
275
+ export async function getProjectStatus(
276
+ kv: KVNamespace,
277
+ projectKey: string
278
+ ): Promise<CircuitBreakerStatusValue | null> {
279
+ return (await kv.get(projectKey)) as CircuitBreakerStatusValue | null;
280
+ }
281
+
282
+ /**
283
+ * Get circuit breaker states for multiple projects.
284
+ * Returns a dynamic record (not hardcoded booleans) for multi-account flexibility.
285
+ *
286
+ * @param kv - KV namespace
287
+ * @param projectKeys - Project keys to check (defaults to all known projects)
288
+ * @returns Record of project slug -> status value
289
+ */
290
+ export async function getCircuitBreakerStates(
291
+ kv: KVNamespace,
292
+ projectKeys?: string[]
293
+ ): Promise<Record<string, CircuitBreakerStatusValue | 'global_stop' | null>> {
294
+ const keys =
295
+ projectKeys ??
296
+ Object.values(CB_PROJECT_KEYS).filter((k) => k !== CB_PROJECT_KEYS.GLOBAL_STOP);
297
+
298
+ const globalStop = await kv.get(GLOBAL_STOP_KEY);
299
+
300
+ const results: Record<string, CircuitBreakerStatusValue | 'global_stop' | null> = {
301
+ globalStop: globalStop === 'true' ? 'global_stop' : null,
302
+ };
303
+
304
+ const statuses = await Promise.all(keys.map((k) => kv.get(k)));
305
+ keys.forEach((key, i) => {
306
+ const slug = key.match(/PROJECT:([^:]+):STATUS/)?.[1]?.toLowerCase() ?? key;
307
+ results[slug] = statuses[i] as CircuitBreakerStatusValue | null;
308
+ });
309
+
310
+ return results;
311
+ }
312
+
313
+ // =============================================================================
314
+ // STATUS WRITES
315
+ // =============================================================================
316
+
317
+ /**
318
+ * Set project circuit breaker status in KV.
319
+ * Used by budget-enforcement and platform-agent to write CB state.
320
+ *
321
+ * @param kv - Target KV namespace (local or remote)
322
+ * @param projectKey - PROJECT:{SLUG}:STATUS key
323
+ * @param status - active/warning/paused
324
+ * @param ttlSeconds - Expiry (default 86400 = 24h, matches budget-enforcement)
325
+ */
326
+ export async function setProjectStatus(
327
+ kv: KVNamespace,
328
+ projectKey: string,
329
+ status: CircuitBreakerStatusValue,
330
+ ttlSeconds: number = 86400
331
+ ): Promise<void> {
332
+ await kv.put(projectKey, status, { expirationTtl: ttlSeconds });
333
+ }
334
+
335
+ // =============================================================================
336
+ // GLOBAL STOP
337
+ // =============================================================================
338
+
339
+ /**
340
+ * Check if global stop is active.
341
+ *
342
+ * @param kv - KV namespace
343
+ * @returns true if global stop is enabled
344
+ */
345
+ export async function isGlobalStopActive(kv: KVNamespace): Promise<boolean> {
346
+ return (await kv.get(GLOBAL_STOP_KEY)) === 'true';
347
+ }
348
+
349
+ /**
350
+ * Set global stop on a specific KV namespace.
351
+ *
352
+ * @param kv - Target KV namespace
353
+ * @param enabled - true to enable global stop, false to disable
354
+ */
355
+ export async function setGlobalStop(kv: KVNamespace, enabled: boolean): Promise<void> {
356
+ if (enabled) {
357
+ await kv.put(GLOBAL_STOP_KEY, 'true');
358
+ } else {
359
+ await kv.delete(GLOBAL_STOP_KEY);
360
+ }
361
+ }
362
+
363
+ // =============================================================================
364
+ // HONO MIDDLEWARE FACTORY
365
+ // =============================================================================
366
+
367
+ /**
368
+ * Hono middleware factory for circuit breaker checks.
369
+ *
370
+ * Features:
371
+ * - Blocks requests when OPEN (paused) with 503 response
372
+ * - Allows requests when WARNING but adds X-Platform-Budget: Warning header
373
+ * - Normal passthrough when CLOSED (active)
374
+ * - Skips configurable paths (default: health endpoints)
375
+ *
376
+ * Uses loose Hono types (no Hono dependency).
377
+ *
378
+ * @param projectKey - The KV key for the project status
379
+ * @param options - Middleware options (e.g., custom skipPaths)
380
+ * @returns Hono-compatible middleware function
381
+ */
382
+ export function createCircuitBreakerMiddleware(
383
+ projectKey: string,
384
+ options?: CircuitBreakerMiddlewareOptions
385
+ ) {
386
+ const skipPaths = options?.skipPaths ?? DEFAULT_SKIP_PATHS;
387
+
388
+ return async (
389
+ c: {
390
+ env: { PLATFORM_CACHE: KVNamespace };
391
+ req: { path: string };
392
+ res: Response;
393
+ },
394
+ next: () => Promise<void | Response>
395
+ ): Promise<void | Response> => {
396
+ // Skip configured paths (allows monitoring during circuit break)
397
+ const path = c.req.path;
398
+ if (skipPaths.some((skip) => path === skip || path.startsWith(skip))) {
399
+ return next();
400
+ }
401
+
402
+ const result = await checkProjectCircuitBreakerDetailed(projectKey, c.env.PLATFORM_CACHE);
403
+
404
+ // OPEN: Block request with 503
405
+ if (!result.allowed && result.response) {
406
+ return result.response;
407
+ }
408
+
409
+ // WARNING: Allow but add header for client visibility
410
+ if (result.status === PROJECT_CB_STATUS.WARNING) {
411
+ await next();
412
+ const response = c.res;
413
+ const newResponse = new Response(response.body, response);
414
+ newResponse.headers.set(BUDGET_STATUS_HEADER, 'Warning');
415
+ return newResponse;
416
+ }
417
+
418
+ // CLOSED: Normal passthrough
419
+ return next();
420
+ };
421
+ }
422
+
423
+ // =============================================================================
424
+ // HELPERS
425
+ // =============================================================================
426
+
427
+ /**
428
+ * Create a standard 503 circuit breaker response.
429
+ */
430
+ function createCircuitBreakerResponse(errorInfo: CircuitBreakerErrorResponse): Response {
431
+ return new Response(
432
+ JSON.stringify({
433
+ success: false,
434
+ error: errorInfo.error,
435
+ code: errorInfo.code,
436
+ retryAfterSeconds: errorInfo.retryAfterSeconds,
437
+ }),
438
+ {
439
+ status: 503,
440
+ headers: {
441
+ 'Content-Type': 'application/json',
442
+ 'Retry-After': String(errorInfo.retryAfterSeconds),
443
+ 'X-Circuit-Breaker': errorInfo.code,
444
+ },
445
+ }
446
+ );
447
+ }
@@ -0,0 +1,156 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ /**
4
+ * Transient Error Patterns
5
+ *
6
+ * Static regex patterns for classifying transient (expected operational) errors.
7
+ * These patterns enable stable category-based fingerprints instead of message-based
8
+ * fingerprints, preventing duplicate issues when external APIs return varying messages.
9
+ *
10
+ * Zero I/O, fully portable — safe to import in any Cloudflare Worker.
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { classifyErrorAsTransient, TRANSIENT_ERROR_PATTERNS } from '@littlebearapps/platform-consumer-sdk/patterns';
15
+ *
16
+ * const result = classifyErrorAsTransient('quotaExceeded: Daily limit reached');
17
+ * // { isTransient: true, category: 'quota-exhausted' }
18
+ *
19
+ * const notTransient = classifyErrorAsTransient('TypeError: Cannot read property x');
20
+ * // { isTransient: false }
21
+ * ```
22
+ */
23
+
24
+ // =============================================================================
25
+ // TYPES
26
+ // =============================================================================
27
+
28
+ /** A static transient error pattern with regex and category */
29
+ export interface TransientErrorPattern {
30
+ pattern: RegExp;
31
+ category: string;
32
+ }
33
+
34
+ // =============================================================================
35
+ // PATTERNS
36
+ // =============================================================================
37
+
38
+ /**
39
+ * Transient error patterns that should use stable category-based fingerprints
40
+ * instead of message-based fingerprints. This prevents duplicate issues when
41
+ * external APIs return slightly different error messages for the same condition.
42
+ *
43
+ * Patterns are checked in order — first match wins.
44
+ * Categories are used as fingerprint components instead of the error message.
45
+ */
46
+ export const TRANSIENT_ERROR_PATTERNS: TransientErrorPattern[] = [
47
+ // Internal quota guards (self-imposed safety limits) - MUST be before generic quota patterns
48
+ { pattern: /safety limit exceeded/i, category: 'quota-safety-limit' },
49
+ { pattern: /QuotaGuard safety limit/i, category: 'quota-safety-limit' },
50
+ { pattern: /safety limit/i, category: 'quota-safety-limit' },
51
+
52
+ // YouTube-specific quota patterns - MUST be before generic quota patterns
53
+ { pattern: /Trending videos quota exceeded/i, category: 'youtube-quota' },
54
+ { pattern: /Video search quota exceeded/i, category: 'youtube-quota' },
55
+
56
+ // Quota exhaustion patterns (most specific first)
57
+ { pattern: /QUOTA.*EXHAUSTED/i, category: 'quota-exhausted' },
58
+ { pattern: /quotaExceeded/i, category: 'quota-exhausted' },
59
+ { pattern: /quota.*exceeded/i, category: 'quota-exhausted' },
60
+ { pattern: /quota.*limit/i, category: 'quota-exhausted' },
61
+ { pattern: /daily.*limit.*exceeded/i, category: 'quota-exhausted' },
62
+
63
+ // Rate limiting patterns
64
+ { pattern: /RATE.*LIMITED/i, category: 'rate-limited' },
65
+ { pattern: /rate.?limit/i, category: 'rate-limited' },
66
+ { pattern: /too.?many.?requests/i, category: 'rate-limited' },
67
+ { pattern: /\b429\b/, category: 'rate-limited' },
68
+
69
+ // Service availability patterns
70
+ { pattern: /service.*unavailable/i, category: 'service-unavailable' },
71
+ { pattern: /\b503\b/, category: 'service-unavailable' },
72
+ { pattern: /\b502\b/, category: 'bad-gateway' },
73
+ { pattern: /bad.*gateway/i, category: 'bad-gateway' },
74
+
75
+ // Connection patterns
76
+ { pattern: /ECONNREFUSED/i, category: 'connection-refused' },
77
+ { pattern: /ETIMEDOUT/i, category: 'connection-timeout' },
78
+ { pattern: /ECONNRESET/i, category: 'connection-reset' },
79
+ { pattern: /ENOTFOUND/i, category: 'dns-not-found' },
80
+
81
+ // Timeout patterns - specific patterns MUST be before generic /timeout/i
82
+ { pattern: /scan timed out/i, category: 'scan-timeout' },
83
+ { pattern: /Platform \w+ timeout/i, category: 'platform-timeout' },
84
+ { pattern: /timeout/i, category: 'timeout' },
85
+
86
+ // Deployment/infrastructure patterns (DO resets during code updates)
87
+ { pattern: /Durable Object reset/i, category: 'do-reset' },
88
+ { pattern: /code was updated/i, category: 'deployment-reset' },
89
+
90
+ // YouTube API patterns (structured logging extracts message without quota fields)
91
+ { pattern: /YOUTUBE_API_ERROR/i, category: 'youtube-api-error' },
92
+ { pattern: /\bquota_exceeded\b/i, category: 'quota-exhausted' },
93
+ { pattern: /Channel lookup failed/i, category: 'channel-lookup-failed' },
94
+ { pattern: /Channel forbidden/i, category: 'channel-forbidden' },
95
+ // YouTube transient fetch failures (403s during quota exhaustion, API issues)
96
+ { pattern: /Playlist fetch failed/i, category: 'youtube-fetch-failed' },
97
+ { pattern: /Video.*fetch failed/i, category: 'youtube-fetch-failed' },
98
+ { pattern: /Subscriptions? fetch failed/i, category: 'youtube-fetch-failed' },
99
+ { pattern: /Get subscriptions failed/i, category: 'youtube-fetch-failed' },
100
+ { pattern: /YouTube subscription sync failed/i, category: 'youtube-fetch-failed' },
101
+
102
+ // D1 patterns (inefficient queries are expected during development)
103
+ { pattern: /D1 inefficient query/i, category: 'd1-inefficient-query' },
104
+ // D1 rate limiting (Cloudflare limits API requests per worker invocation)
105
+ { pattern: /Too many API requests by single worker/i, category: 'd1-rate-limited' },
106
+
107
+ // Durable Object stub errors (transient during deployments/resets)
108
+ { pattern: /DO stub error/i, category: 'do-stub-error' },
109
+ { pattern: /DO transient error/i, category: 'do-stub-error' },
110
+ { pattern: /stub\.fetch is not a function/i, category: 'do-stub-error' },
111
+ { pattern: /\bdestroyed\b/, category: 'do-destroyed' },
112
+
113
+ // Cloudflare platform behaviour (runtime warnings, R2 transient errors)
114
+ { pattern: /promise was resolved or rejected from a different request context/i, category: 'cross-request-promise' },
115
+ { pattern: /We encountered an internal error.*\(10001\)/i, category: 'r2-internal-error' },
116
+ { pattern: /Failed to log AI call to R2/i, category: 'r2-logging-failed' },
117
+
118
+ // Brand Copilot expected operational patterns
119
+ { pattern: /\[SEC\] workers\.dev auth FAILED/i, category: 'auth-rejected-workersdev' },
120
+ { pattern: /Budget exhausted, skipping/i, category: 'budget-exhausted' },
121
+ { pattern: /\[Gatekeeper\] AI error, failing open/i, category: 'gatekeeper-fail-open' },
122
+ { pattern: /Mastodon OAuth not configured/i, category: 'mastodon-oauth-missing' },
123
+ { pattern: /Error fetching trending/i, category: 'external-api-trending' },
124
+ { pattern: /Failed to scan discover feed/i, category: 'scanner-discover-feed' },
125
+ ];
126
+
127
+ // =============================================================================
128
+ // CLASSIFICATION
129
+ // =============================================================================
130
+
131
+ /**
132
+ * Classify an error message as transient or not.
133
+ *
134
+ * Checks the message against all static transient error patterns.
135
+ * Returns the category if matched, or `isTransient: false` if the error
136
+ * should use standard message-based fingerprinting.
137
+ *
138
+ * @param message - The error message to classify
139
+ * @returns Classification result with category if transient
140
+ *
141
+ * @example
142
+ * ```typescript
143
+ * const result = classifyErrorAsTransient('RATE_LIMITED: Too many requests');
144
+ * if (result.isTransient) {
145
+ * console.log(`Transient error: ${result.category}`); // 'rate-limited'
146
+ * }
147
+ * ```
148
+ */
149
+ export function classifyErrorAsTransient(message: string): { isTransient: boolean; category?: string } {
150
+ for (const { pattern, category } of TRANSIENT_ERROR_PATTERNS) {
151
+ if (pattern.test(message)) {
152
+ return { isTransient: true, category };
153
+ }
154
+ }
155
+ return { isTransient: false };
156
+ }