te.js 2.1.6 → 2.2.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.
Files changed (43) hide show
  1. package/auto-docs/analysis/handler-analyzer.test.js +106 -0
  2. package/auto-docs/analysis/source-resolver.test.js +58 -0
  3. package/auto-docs/constants.js +13 -2
  4. package/auto-docs/openapi/generator.js +7 -5
  5. package/auto-docs/openapi/generator.test.js +132 -0
  6. package/auto-docs/openapi/spec-builders.js +39 -19
  7. package/cli/docs-command.js +44 -36
  8. package/cors/index.test.js +82 -0
  9. package/database/index.js +3 -1
  10. package/database/mongodb.js +17 -11
  11. package/database/redis.js +53 -44
  12. package/lib/llm/client.js +6 -1
  13. package/lib/llm/index.js +14 -1
  14. package/lib/llm/parse.test.js +60 -0
  15. package/package.json +3 -1
  16. package/radar/index.js +281 -0
  17. package/rate-limit/index.js +8 -11
  18. package/rate-limit/index.test.js +64 -0
  19. package/server/ammo/body-parser.js +156 -152
  20. package/server/ammo/body-parser.test.js +79 -0
  21. package/server/ammo/enhancer.js +8 -4
  22. package/server/ammo.js +135 -10
  23. package/server/context/request-context.js +51 -0
  24. package/server/context/request-context.test.js +53 -0
  25. package/server/endpoint.js +15 -0
  26. package/server/error.js +56 -3
  27. package/server/error.test.js +45 -0
  28. package/server/errors/channels/channels.test.js +148 -0
  29. package/server/errors/channels/index.js +1 -1
  30. package/server/errors/llm-cache.js +1 -1
  31. package/server/errors/llm-cache.test.js +160 -0
  32. package/server/errors/llm-error-service.js +1 -1
  33. package/server/errors/llm-rate-limiter.test.js +105 -0
  34. package/server/files/uploader.js +38 -26
  35. package/server/handler.js +1 -1
  36. package/server/targets/registry.js +3 -3
  37. package/server/targets/registry.test.js +108 -0
  38. package/te.js +178 -49
  39. package/utils/auto-register.js +1 -1
  40. package/utils/configuration.js +23 -9
  41. package/utils/configuration.test.js +58 -0
  42. package/utils/errors-llm-config.js +11 -8
  43. package/utils/request-logger.js +49 -3
package/server/ammo.js CHANGED
@@ -15,6 +15,9 @@ import {
15
15
  buildPayload,
16
16
  dispatchToChannels,
17
17
  } from './errors/channels/index.js';
18
+ import TejLogger from 'tej-logger';
19
+
20
+ const logger = new TejLogger('Tejas.Ammo');
18
21
 
19
22
  /**
20
23
  * Detect if the value is a throw() options object (per-call overrides).
@@ -85,6 +88,13 @@ class Ammo {
85
88
 
86
89
  // Response related data
87
90
  this.dispatchedData = undefined;
91
+
92
+ /**
93
+ * Resolved error info stashed after ammo.throw() completes.
94
+ * Read by the radar middleware on res.finish to populate error tracking.
95
+ * @type {{ message: string, type: string|null, devInsight: string|null, stack: string|null, codeContext: object|null } | null}
96
+ */
97
+ this._errorInfo = null;
88
98
  }
89
99
 
90
100
  /**
@@ -371,7 +381,11 @@ class Ammo {
371
381
  // Per-call options: last arg can be { useLlm?, messageType? } when call is LLM-eligible (no explicit code).
372
382
  const llmEligible =
373
383
  args.length === 0 ||
374
- (!isStatusCode(args[0]) && !(args[0] instanceof TejError));
384
+ (!isStatusCode(args[0]) &&
385
+ !(
386
+ typeof args[0]?.statusCode === 'number' &&
387
+ typeof args[0]?.code === 'string'
388
+ ));
375
389
  let throwOpts =
376
390
  /** @type {{ useLlm?: boolean, messageType?: 'endUser'|'developer' } | null} */ (
377
391
  null
@@ -393,7 +407,7 @@ class Ammo {
393
407
  // Capture the stack string SYNCHRONOUSLY before any async work or fire() call,
394
408
  // because the call stack unwinds as soon as we await or respond.
395
409
  const stack =
396
- args[0] instanceof Error && args[0].stack
410
+ args[0] != null && typeof args[0].stack === 'string'
397
411
  ? args[0].stack
398
412
  : new Error().stack;
399
413
  const originalError =
@@ -405,11 +419,31 @@ class Ammo {
405
419
  // Respond immediately with a generic 500, then run LLM in the background.
406
420
  this.fire(500, 'Internal Server Error');
407
421
 
422
+ // Stash basic error info synchronously so radar can read it on res.finish
423
+ // even before LLM completes. LLM result will update _errorInfo when ready.
424
+ const errorType =
425
+ originalError != null &&
426
+ typeof originalError.constructor?.name === 'string'
427
+ ? originalError.constructor.name
428
+ : originalError !== undefined
429
+ ? typeof originalError
430
+ : null;
431
+ this._errorInfo = {
432
+ message: 'Internal Server Error',
433
+ type: errorType,
434
+ devInsight: null,
435
+ stack: stack ?? null,
436
+ codeContext: null,
437
+ };
438
+
408
439
  // Fire-and-forget: capture context, call LLM, dispatch to channel.
409
440
  const method = this.method;
410
441
  const path = this.path;
442
+ const self = this;
411
443
  captureCodeContext(stack)
412
444
  .then((codeContext) => {
445
+ // Update _errorInfo with captured code context
446
+ if (self._errorInfo) self._errorInfo.codeContext = codeContext;
413
447
  const context = {
414
448
  codeContext,
415
449
  method,
@@ -428,6 +462,11 @@ class Ammo {
428
462
  }));
429
463
  })
430
464
  .then(({ result, codeContext }) => {
465
+ // Update _errorInfo with full LLM result
466
+ if (self._errorInfo) {
467
+ self._errorInfo.message = result.message;
468
+ self._errorInfo.devInsight = result.devInsight ?? null;
469
+ }
431
470
  const channels = getChannels(channel, logFile);
432
471
  const payload = buildPayload({
433
472
  method,
@@ -442,8 +481,12 @@ class Ammo {
442
481
  });
443
482
  return dispatchToChannels(channels, payload);
444
483
  })
445
- .catch(() => {
446
- // Swallow background errors the HTTP response has already been sent.
484
+ .catch((err) => {
485
+ // Background LLM failed after HTTP response already sent — log the failure
486
+ // but do not attempt to respond again.
487
+ logger.warn(
488
+ `Background LLM dispatch failed: ${err?.message ?? err}`,
489
+ );
447
490
  });
448
491
 
449
492
  return;
@@ -462,9 +505,27 @@ class Ammo {
462
505
  }),
463
506
  };
464
507
  if (originalError !== undefined) context.error = originalError;
465
- return inferErrorFromContext(context);
508
+ return inferErrorFromContext(context).then((result) => ({
509
+ result,
510
+ codeContext,
511
+ }));
466
512
  })
467
- .then(({ statusCode, message, devInsight }) => {
513
+ .then(({ result, codeContext }) => {
514
+ const { statusCode, message, devInsight } = result;
515
+ const errorType =
516
+ originalError != null &&
517
+ typeof originalError.constructor?.name === 'string'
518
+ ? originalError.constructor.name
519
+ : originalError !== undefined
520
+ ? typeof originalError
521
+ : null;
522
+ this._errorInfo = {
523
+ message,
524
+ type: errorType,
525
+ devInsight: devInsight ?? null,
526
+ stack: stack ?? null,
527
+ codeContext: codeContext ?? null,
528
+ };
468
529
  const isProduction = process.env.NODE_ENV === 'production';
469
530
  const data =
470
531
  !isProduction && devInsight
@@ -472,15 +533,23 @@ class Ammo {
472
533
  : message;
473
534
  this.fire(statusCode, data);
474
535
  })
475
- .catch(() => {
536
+ .catch((err) => {
476
537
  // LLM call failed (network error, timeout, etc.) — fall back to generic 500
477
538
  // so the client always gets a response and we don't trigger an infinite retry loop.
539
+ logger.warn(`LLM error inference failed: ${err?.message ?? err}`);
478
540
  this.fire(500, 'Internal Server Error');
479
541
  });
480
542
  }
481
543
 
482
544
  // Sync path: explicit code/message or useLlm: false
483
545
  if (args.length === 0) {
546
+ this._errorInfo = {
547
+ message: 'Internal Server Error',
548
+ type: null,
549
+ devInsight: null,
550
+ stack: null,
551
+ codeContext: null,
552
+ };
484
553
  this.fire(500, 'Internal Server Error');
485
554
  return;
486
555
  }
@@ -488,29 +557,71 @@ class Ammo {
488
557
  if (isStatusCode(args[0])) {
489
558
  const statusCode = args[0];
490
559
  const message = args[1] || toStatusMessage(statusCode);
560
+ this._errorInfo = {
561
+ message,
562
+ type: null,
563
+ devInsight: null,
564
+ stack: null,
565
+ codeContext: null,
566
+ };
491
567
  this.fire(statusCode, message);
492
568
  return;
493
569
  }
494
570
 
495
- if (args[0] instanceof TejError) {
571
+ if (
572
+ typeof args[0]?.statusCode === 'number' &&
573
+ typeof args[0]?.code === 'string'
574
+ ) {
496
575
  const error = args[0];
497
- this.fire(error.code, error.message);
576
+ this._errorInfo = {
577
+ message: error.message,
578
+ type: error.constructor?.name ?? 'TejError',
579
+ devInsight: null,
580
+ stack: error.stack ?? null,
581
+ codeContext: null,
582
+ };
583
+ this.fire(error.statusCode, error.message);
498
584
  return;
499
585
  }
500
586
 
501
- if (args[0] instanceof Error) {
587
+ if (
588
+ args[0] != null &&
589
+ typeof args[0].message === 'string' &&
590
+ typeof args[0].stack === 'string'
591
+ ) {
502
592
  const error = args[0];
503
593
  if (!isNaN(parseInt(error.message))) {
504
594
  const statusCode = parseInt(error.message);
505
595
  const message = toStatusMessage(statusCode) || toStatusMessage(500);
596
+ this._errorInfo = {
597
+ message,
598
+ type: error.constructor.name,
599
+ devInsight: null,
600
+ stack: error.stack ?? null,
601
+ codeContext: null,
602
+ };
506
603
  this.fire(statusCode, message);
507
604
  return;
508
605
  }
509
606
  const statusCode = toStatusCode(error.message);
510
607
  if (statusCode) {
608
+ this._errorInfo = {
609
+ message: error.message,
610
+ type: error.constructor.name,
611
+ devInsight: null,
612
+ stack: error.stack ?? null,
613
+ codeContext: null,
614
+ };
511
615
  this.fire(statusCode, error.message);
512
616
  return;
513
617
  }
618
+ this._errorInfo = {
619
+ message: error.message,
620
+ type: error.constructor.name,
621
+ devInsight: null,
622
+ stack: error.stack ?? null,
623
+ codeContext: null,
624
+ };
514
625
  this.fire(500, error.message);
515
626
  return;
516
627
  }
@@ -518,9 +629,23 @@ class Ammo {
518
629
  const errorValue = args[0];
519
630
  const statusCode = toStatusCode(errorValue);
520
631
  if (statusCode) {
632
+ this._errorInfo = {
633
+ message: toStatusMessage(statusCode),
634
+ type: null,
635
+ devInsight: null,
636
+ stack: null,
637
+ codeContext: null,
638
+ };
521
639
  this.fire(statusCode, toStatusMessage(statusCode));
522
640
  return;
523
641
  }
642
+ this._errorInfo = {
643
+ message: errorValue.toString(),
644
+ type: null,
645
+ devInsight: null,
646
+ stack: null,
647
+ codeContext: null,
648
+ };
524
649
  this.fire(500, errorValue.toString());
525
650
  }
526
651
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @fileoverview Request-scoped context using AsyncLocalStorage.
3
+ *
4
+ * Provides a safe, mutation-resistant store for per-request data.
5
+ * The store is initialized by the handler and is accessible from any
6
+ * code running within the same async execution context (middleware, handlers,
7
+ * service functions, etc.) without needing to thread parameters through.
8
+ *
9
+ * @example
10
+ * // In a middleware or handler:
11
+ * import { getRequestId, getRequestStore } from '../server/context/request-context.js';
12
+ * const requestId = getRequestId(); // 'abc-123...' or 'no-context'
13
+ */
14
+
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ /** @type {AsyncLocalStorage<{ requestId: string, startTime: number }>} */
19
+ export const requestContext = new AsyncLocalStorage();
20
+
21
+ /**
22
+ * Returns the current request ID from async context, or 'no-context' if called
23
+ * outside of an active request (e.g., during startup).
24
+ * @returns {string}
25
+ */
26
+ export function getRequestId() {
27
+ return requestContext.getStore()?.requestId ?? 'no-context';
28
+ }
29
+
30
+ /**
31
+ * Returns the full request store, or null if not in a request context.
32
+ * @returns {{ requestId: string, startTime: number } | undefined}
33
+ */
34
+ export function getRequestStore() {
35
+ return requestContext.getStore();
36
+ }
37
+
38
+ /**
39
+ * Middleware that initializes the per-request AsyncLocalStorage store.
40
+ * Must be the first global middleware registered via app.midair().
41
+ *
42
+ * @param {import('../ammo.js').default} ammo
43
+ * @param {function(): Promise<void>} next
44
+ */
45
+ export function contextMiddleware(ammo, next) {
46
+ const store = {
47
+ requestId: randomUUID(),
48
+ startTime: Date.now(),
49
+ };
50
+ return requestContext.run(store, next);
51
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @fileoverview Tests for request-context (AsyncLocalStorage).
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import {
6
+ requestContext,
7
+ getRequestId,
8
+ getRequestStore,
9
+ contextMiddleware,
10
+ } from './request-context.js';
11
+
12
+ describe('requestContext', () => {
13
+ it('should return "no-context" when called outside a request', () => {
14
+ expect(getRequestId()).toBe('no-context');
15
+ });
16
+
17
+ it('should return undefined store outside a request', () => {
18
+ expect(getRequestStore()).toBeUndefined();
19
+ });
20
+
21
+ it('should provide requestId within contextMiddleware', async () => {
22
+ const results = [];
23
+ const next = async () => {
24
+ results.push(getRequestId());
25
+ };
26
+ await contextMiddleware({}, next);
27
+ expect(results).toHaveLength(1);
28
+ expect(typeof results[0]).toBe('string');
29
+ expect(results[0]).toMatch(
30
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
31
+ );
32
+ });
33
+
34
+ it('should provide startTime within contextMiddleware', async () => {
35
+ const before = Date.now();
36
+ let store;
37
+ await contextMiddleware({}, async () => {
38
+ store = getRequestStore();
39
+ });
40
+ expect(store.startTime).toBeGreaterThanOrEqual(before);
41
+ });
42
+
43
+ it('each request should have unique requestId', async () => {
44
+ const ids = [];
45
+ for (let i = 0; i < 5; i++) {
46
+ await contextMiddleware({}, async () => {
47
+ ids.push(getRequestId());
48
+ });
49
+ }
50
+ const unique = new Set(ids);
51
+ expect(unique.size).toBe(5);
52
+ });
53
+ });
@@ -2,7 +2,16 @@ import isMiddlewareValid from './targets/middleware-validator.js';
2
2
  import { isPathValid, standardizePath } from './targets/path-validator.js';
3
3
  import isShootValid from './targets/shoot-validator.js';
4
4
 
5
+ /**
6
+ * Represents a single route endpoint: a path, handler, optional middlewares,
7
+ * allowed HTTP methods, and documentation metadata.
8
+ *
9
+ * Use the fluent builder methods to configure before registering with a Target.
10
+ */
5
11
  class Endpoint {
12
+ /**
13
+ * Create a new Endpoint with empty defaults.
14
+ */
6
15
  constructor() {
7
16
  this.path = '';
8
17
  this.middlewares = [];
@@ -14,6 +23,12 @@ class Endpoint {
14
23
  this.group = null;
15
24
  }
16
25
 
26
+ /**
27
+ * Set the full path by combining a base path and a path segment.
28
+ * @param {string} base - Base path (e.g. '/api')
29
+ * @param {string} path - Route path segment (e.g. '/users')
30
+ * @returns {Endpoint}
31
+ */
17
32
  setPath(base, path) {
18
33
  const standardizedBase = standardizePath(base);
19
34
  const standardizedPath = standardizePath(path);
package/server/error.js CHANGED
@@ -1,9 +1,62 @@
1
+ /**
2
+ * @fileoverview Base error class for the Tejas framework.
3
+ *
4
+ * All errors thrown by the framework extend TejError. The constructor
5
+ * accepts the HTTP status code as the first argument (matching the existing
6
+ * call signature `new TejError(statusCode, message)`) and derives a
7
+ * machine-readable `code` string of the form `ERR_HTTP_<statusCode>`.
8
+ *
9
+ * Well-known codes are available as named constants on `TejError` for
10
+ * callers that want expressive error codes without hardcoding numbers.
11
+ */
12
+
13
+ /**
14
+ * Base framework error class.
15
+ *
16
+ * @extends {Error}
17
+ *
18
+ * @example
19
+ * throw new TejError(404, 'Not Found');
20
+ * // error.statusCode === 404
21
+ * // error.code === 'ERR_HTTP_404'
22
+ *
23
+ * @example
24
+ * // With cause chaining
25
+ * throw new TejError(500, 'Database failure', { cause: originalError });
26
+ */
1
27
  class TejError extends Error {
2
- constructor(code, message) {
3
- super(message);
28
+ /**
29
+ * @param {number} statusCode - HTTP status code (e.g. 404, 500)
30
+ * @param {string} message - Human-readable description
31
+ * @param {{ cause?: Error }} [options] - Optional native cause for chaining
32
+ */
33
+ constructor(statusCode, message, options) {
34
+ super(message, options);
4
35
  this.name = this.constructor.name;
5
- this.code = code;
36
+ /** @type {number} HTTP status code */
37
+ this.statusCode = statusCode;
38
+ /** @type {string} Machine-readable error code derived from status */
39
+ this.code = `ERR_HTTP_${statusCode}`;
40
+ Error.captureStackTrace(this, this.constructor);
6
41
  }
7
42
  }
8
43
 
44
+ /**
45
+ * Named error codes for common framework scenarios.
46
+ * Use these instead of hardcoding numeric status codes at call sites.
47
+ */
48
+ TejError.CODES = Object.freeze({
49
+ ERR_ROUTING_FAILED: 'ERR_ROUTING_FAILED',
50
+ ERR_INVALID_DEPENDENCY: 'ERR_INVALID_DEPENDENCY',
51
+ ERR_PLUGIN_LOAD_FAILED: 'ERR_PLUGIN_LOAD_FAILED',
52
+ ERR_CONFIG_INVALID: 'ERR_CONFIG_INVALID',
53
+ ERR_STREAM_OVERFLOW: 'ERR_STREAM_OVERFLOW',
54
+ ERR_AUTH_FAILED: 'ERR_AUTH_FAILED',
55
+ ERR_NOT_FOUND: 'ERR_HTTP_404',
56
+ ERR_METHOD_NOT_ALLOWED: 'ERR_HTTP_405',
57
+ ERR_UNAUTHORIZED: 'ERR_HTTP_401',
58
+ ERR_BAD_REQUEST: 'ERR_HTTP_400',
59
+ ERR_INTERNAL: 'ERR_HTTP_500',
60
+ });
61
+
9
62
  export default TejError;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @fileoverview Tests for TejError base class.
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import TejError from './error.js';
6
+
7
+ describe('TejError', () => {
8
+ it('should set statusCode and derive ERR_HTTP_* code', () => {
9
+ const err = new TejError(404, 'Not Found');
10
+ expect(err.statusCode).toBe(404);
11
+ expect(err.code).toBe('ERR_HTTP_404');
12
+ expect(err.message).toBe('Not Found');
13
+ expect(err.name).toBe('TejError');
14
+ });
15
+
16
+ it('should capture a stack trace', () => {
17
+ const err = new TejError(500, 'Internal Server Error');
18
+ expect(typeof err.stack).toBe('string');
19
+ expect(err.stack).toContain('TejError');
20
+ });
21
+
22
+ it('should support native cause chaining', () => {
23
+ const cause = new Error('original');
24
+ const err = new TejError(500, 'Wrapped', { cause });
25
+ expect(err.cause).toBe(cause);
26
+ });
27
+
28
+ it('should extend Error', () => {
29
+ const err = new TejError(400, 'Bad Request');
30
+ expect(err).toBeInstanceOf(Error);
31
+ });
32
+
33
+ it('should have frozen CODES constant', () => {
34
+ expect(Object.isFrozen(TejError.CODES)).toBe(true);
35
+ expect(TejError.CODES.ERR_ROUTING_FAILED).toBe('ERR_ROUTING_FAILED');
36
+ expect(TejError.CODES.ERR_NOT_FOUND).toBe('ERR_HTTP_404');
37
+ });
38
+
39
+ it('should derive correct codes for common statuses', () => {
40
+ expect(new TejError(400, '').code).toBe('ERR_HTTP_400');
41
+ expect(new TejError(401, '').code).toBe('ERR_HTTP_401');
42
+ expect(new TejError(403, '').code).toBe('ERR_HTTP_403');
43
+ expect(new TejError(500, '').code).toBe('ERR_HTTP_500');
44
+ });
45
+ });
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { getChannels, buildPayload } from './index.js';
3
+ import { ConsoleChannel } from './console.js';
4
+ import { LogChannel } from './log.js';
5
+
6
+ describe('getChannels()', () => {
7
+ it('returns a ConsoleChannel for "console"', () => {
8
+ const channels = getChannels('console', './test.log');
9
+ expect(channels).toHaveLength(1);
10
+ expect(channels[0]).toBeInstanceOf(ConsoleChannel);
11
+ });
12
+
13
+ it('returns a LogChannel for "log"', () => {
14
+ const channels = getChannels('log', './test.log');
15
+ expect(channels).toHaveLength(1);
16
+ expect(channels[0]).toBeInstanceOf(LogChannel);
17
+ });
18
+
19
+ it('returns both channels for "both"', () => {
20
+ const channels = getChannels('both', './test.log');
21
+ expect(channels).toHaveLength(2);
22
+ const types = channels.map((c) => c.constructor.name);
23
+ expect(types).toContain('ConsoleChannel');
24
+ expect(types).toContain('LogChannel');
25
+ });
26
+
27
+ it('defaults to ConsoleChannel for unknown values', () => {
28
+ const channels = getChannels('unknown', './test.log');
29
+ expect(channels).toHaveLength(1);
30
+ expect(channels[0]).toBeInstanceOf(ConsoleChannel);
31
+ });
32
+
33
+ it('returns same ConsoleChannel singleton across calls', () => {
34
+ const a = getChannels('console', './test.log')[0];
35
+ const b = getChannels('console', './test.log')[0];
36
+ expect(a).toBe(b);
37
+ });
38
+
39
+ it('LogChannel uses the provided logFile path', () => {
40
+ const channels = getChannels('log', './my-errors.log');
41
+ expect(channels[0].logFile).toBe('./my-errors.log');
42
+ });
43
+ });
44
+
45
+ describe('buildPayload()', () => {
46
+ const codeContext = {
47
+ snippets: [
48
+ { file: '/app/handler.js', line: 10, snippet: '→ ammo.throw()' },
49
+ ],
50
+ };
51
+
52
+ it('builds a complete payload', () => {
53
+ const payload = buildPayload({
54
+ method: 'POST',
55
+ path: '/users',
56
+ originalError: new Error('DB error'),
57
+ codeContext,
58
+ statusCode: 500,
59
+ message: 'Internal Server Error',
60
+ devInsight: 'DB connection may be down.',
61
+ });
62
+
63
+ expect(payload.method).toBe('POST');
64
+ expect(payload.path).toBe('/users');
65
+ expect(payload.statusCode).toBe(500);
66
+ expect(payload.message).toBe('Internal Server Error');
67
+ expect(payload.devInsight).toBe('DB connection may be down.');
68
+ expect(payload.error).toEqual({ type: 'Error', message: 'DB error' });
69
+ expect(payload.codeContext).toBe(codeContext);
70
+ expect(typeof payload.timestamp).toBe('string');
71
+ expect(() => new Date(payload.timestamp)).not.toThrow();
72
+ });
73
+
74
+ it('handles string error', () => {
75
+ const payload = buildPayload({
76
+ method: 'GET',
77
+ path: '/items',
78
+ originalError: 'Not found',
79
+ codeContext,
80
+ statusCode: 404,
81
+ message: 'Not found',
82
+ });
83
+ expect(payload.error).toEqual({ type: 'string', message: 'Not found' });
84
+ });
85
+
86
+ it('sets error to null when no originalError', () => {
87
+ const payload = buildPayload({
88
+ method: 'GET',
89
+ path: '/',
90
+ originalError: null,
91
+ codeContext,
92
+ statusCode: 500,
93
+ message: 'Error',
94
+ });
95
+ expect(payload.error).toBeNull();
96
+ });
97
+
98
+ it('includes cached flag when provided', () => {
99
+ const payload = buildPayload({
100
+ method: 'GET',
101
+ path: '/',
102
+ originalError: null,
103
+ codeContext,
104
+ statusCode: 404,
105
+ message: 'Not found',
106
+ cached: true,
107
+ });
108
+ expect(payload.cached).toBe(true);
109
+ });
110
+
111
+ it('includes rateLimited flag when provided', () => {
112
+ const payload = buildPayload({
113
+ method: 'GET',
114
+ path: '/',
115
+ originalError: null,
116
+ codeContext,
117
+ statusCode: 500,
118
+ message: 'Error',
119
+ rateLimited: true,
120
+ });
121
+ expect(payload.rateLimited).toBe(true);
122
+ });
123
+
124
+ it('omits cached and rateLimited when not provided', () => {
125
+ const payload = buildPayload({
126
+ method: 'GET',
127
+ path: '/',
128
+ originalError: null,
129
+ codeContext,
130
+ statusCode: 200,
131
+ message: 'OK',
132
+ });
133
+ expect(payload).not.toHaveProperty('cached');
134
+ expect(payload).not.toHaveProperty('rateLimited');
135
+ });
136
+
137
+ it('omits devInsight when not provided', () => {
138
+ const payload = buildPayload({
139
+ method: 'GET',
140
+ path: '/',
141
+ originalError: null,
142
+ codeContext,
143
+ statusCode: 500,
144
+ message: 'Error',
145
+ });
146
+ expect(payload).not.toHaveProperty('devInsight');
147
+ });
148
+ });
@@ -77,7 +77,7 @@ export function buildPayload({
77
77
  rateLimited,
78
78
  }) {
79
79
  let errorSummary = null;
80
- if (originalError instanceof Error) {
80
+ if (originalError != null && typeof originalError.message === 'string') {
81
81
  errorSummary = {
82
82
  type: originalError.constructor?.name ?? 'Error',
83
83
  message: originalError.message ?? '',
@@ -29,7 +29,7 @@ class LLMErrorCache {
29
29
  const snippet = codeContext?.snippets?.[0];
30
30
  const location = snippet ? `${snippet.file}:${snippet.line}` : 'unknown';
31
31
  let errText = '';
32
- if (error instanceof Error) {
32
+ if (error != null && typeof error.message === 'string') {
33
33
  errText = error.message ?? '';
34
34
  } else if (error != null) {
35
35
  errText = String(error);