@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.
- package/README.md +306 -0
- package/package.json +53 -0
- package/src/ai-gateway.ts +305 -0
- package/src/constants.ts +147 -0
- package/src/costs.ts +590 -0
- package/src/do-heartbeat.ts +249 -0
- package/src/dynamic-patterns.ts +273 -0
- package/src/errors.ts +285 -0
- package/src/features.ts +149 -0
- package/src/heartbeat.ts +27 -0
- package/src/index.ts +950 -0
- package/src/logging.ts +543 -0
- package/src/middleware.ts +447 -0
- package/src/patterns.ts +156 -0
- package/src/proxy.ts +732 -0
- package/src/retry.ts +19 -0
- package/src/service-client.ts +291 -0
- package/src/telemetry.ts +342 -0
- package/src/timeout.ts +212 -0
- package/src/tracing.ts +403 -0
- package/src/types.ts +465 -0
|
@@ -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
|
+
}
|
package/src/patterns.ts
ADDED
|
@@ -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
|
+
}
|