From 3e4d16d69bdda18023535b784b3b18e614883315 Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Wed, 9 Jul 2025 12:20:54 +0100 Subject: [PATCH 1/2] Added anomaly type Based on the Cognitect Labs anomalies library, this should be a generic, simple set of errors that are both actionable and easy to translate to HTTP errors when needed. --- src/core/anomaly.ts | 140 +++++++++++++++++++++++++++++++ src/core/anomaly.unit.test.ts | 152 ++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 src/core/anomaly.ts create mode 100644 src/core/anomaly.unit.test.ts diff --git a/src/core/anomaly.ts b/src/core/anomaly.ts new file mode 100644 index 000000000..115076f4a --- /dev/null +++ b/src/core/anomaly.ts @@ -0,0 +1,140 @@ +type AnomalyCategory = + | 'unavailable' + | 'interrupted' + | 'busy' + | 'incorrect' + | 'forbidden' + | 'unsupported' + | 'not-found' + | 'conflict' + | 'fault'; + +type AnomalyOptions = { + message?: string; + metadata?: Record; +}; + +type MaybeRetryableAnomalyOptions = AnomalyOptions & { + retryable?: boolean; +}; + +class Anomaly extends Error { + public readonly category: AnomalyCategory; + public readonly retryable: boolean; + public readonly metadata?: Record; + + constructor( + category: AnomalyCategory, + message?: string, + retryable?: boolean, + metadata?: Record, + ) { + const defaultMessage = getDefaultMessage(category); + super(message || defaultMessage); + + this.name = 'Anomaly'; + this.category = category; + this.retryable = retryable || getRetryable(category); + this.metadata = metadata; + } +} + +export class UnavailableAnomaly extends Anomaly { + constructor(options?: AnomalyOptions) { + super('unavailable', options?.message, undefined, options?.metadata); + } +} + +export class InterruptedAnomaly extends Anomaly { + constructor(options?: MaybeRetryableAnomalyOptions) { + super( + 'interrupted', + options?.message, + options?.retryable, + options?.metadata, + ); + } +} + +export class BusyAnomaly extends Anomaly { + constructor(options?: AnomalyOptions) { + super('busy', options?.message, undefined, options?.metadata); + } +} + +export class IncorrectAnomaly extends Anomaly { + constructor(options?: AnomalyOptions) { + super('incorrect', options?.message, undefined, options?.metadata); + } +} + +export class ForbiddenAnomaly extends Anomaly { + constructor(options?: AnomalyOptions) { + super('forbidden', options?.message, undefined, options?.metadata); + } +} + +export class UnsupportedAnomaly extends Anomaly { + constructor(options?: AnomalyOptions) { + super('unsupported', options?.message, undefined, options?.metadata); + } +} + +export class NotFoundAnomaly extends Anomaly { + constructor(options?: AnomalyOptions) { + super('not-found', options?.message, undefined, options?.metadata); + } +} + +export class ConflictAnomaly extends Anomaly { + constructor(options?: AnomalyOptions) { + super('conflict', options?.message, undefined, options?.metadata); + } +} + +export class FaultAnomaly extends Anomaly { + constructor(options?: MaybeRetryableAnomalyOptions) { + super('fault', options?.message, options?.retryable, options?.metadata); + } +} + +function getDefaultMessage(category: AnomalyCategory): string { + switch (category) { + case 'unavailable': + return 'Service is unavailable'; + case 'interrupted': + return 'Operation was interrupted'; + case 'busy': + return 'Service is busy'; + case 'incorrect': + return 'Request is incorrect'; + case 'forbidden': + return 'Access forbidden'; + case 'unsupported': + return 'Operation not supported'; + case 'not-found': + return 'Resource not found'; + case 'conflict': + return 'Request conflicts with current state'; + case 'fault': + return 'Internal service fault'; + } +} + +function getRetryable(category: AnomalyCategory): boolean { + switch (category) { + case 'unavailable': + case 'busy': + return true; + case 'interrupted': + case 'fault': + // "Maybe" results are false by default, but overridable on construction + return false; + case 'incorrect': + case 'forbidden': + case 'unsupported': + case 'not-found': + case 'conflict': + return false; + } +} diff --git a/src/core/anomaly.unit.test.ts b/src/core/anomaly.unit.test.ts new file mode 100644 index 000000000..2d7514651 --- /dev/null +++ b/src/core/anomaly.unit.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import { + BusyAnomaly, + ConflictAnomaly, + FaultAnomaly, + ForbiddenAnomaly, + IncorrectAnomaly, + InterruptedAnomaly, + NotFoundAnomaly, + UnavailableAnomaly, + UnsupportedAnomaly, +} from './anomaly'; + +describe('Anomalies', () => { + describe('Specific anomaly classes', () => { + it('should create UnavailableAnomaly correctly', () => { + const anomaly = new UnavailableAnomaly(); + expect(anomaly).toMatchObject({ + category: 'unavailable', + message: 'Service is unavailable', + retryable: true, + }); + }); + + it('should create InterruptedAnomaly correctly', () => { + const anomaly = new InterruptedAnomaly({ + message: 'Custom interrupted message', + }); + expect(anomaly).toMatchObject({ + category: 'interrupted', + message: 'Custom interrupted message', + retryable: false, + }); + }); + + it('should create BusyAnomaly correctly', () => { + const anomaly = new BusyAnomaly(); + expect(anomaly).toMatchObject({ + category: 'busy', + retryable: true, + }); + }); + + it('should create IncorrectAnomaly correctly', () => { + const anomaly = new IncorrectAnomaly(); + expect(anomaly).toMatchObject({ + category: 'incorrect', + retryable: false, + }); + }); + + it('should create ForbiddenAnomaly correctly', () => { + const anomaly = new ForbiddenAnomaly(); + expect(anomaly).toMatchObject({ + category: 'forbidden', + retryable: false, + }); + }); + + it('should create UnsupportedAnomaly correctly', () => { + const anomaly = new UnsupportedAnomaly(); + expect(anomaly).toMatchObject({ + category: 'unsupported', + retryable: false, + }); + }); + + it('should create NotFoundAnomaly correctly', () => { + const anomaly = new NotFoundAnomaly(); + expect(anomaly).toMatchObject({ + category: 'not-found', + retryable: false, + }); + }); + + it('should create ConflictAnomaly correctly', () => { + const anomaly = new ConflictAnomaly(); + expect(anomaly).toMatchObject({ + category: 'conflict', + retryable: false, + }); + }); + + it('should create FaultAnomaly correctly', () => { + const anomaly = new FaultAnomaly(); + expect(anomaly).toMatchObject({ + category: 'fault', + retryable: false, + }); + }); + }); + + describe('Edge cases', () => { + it('should use default messages when no custom message provided', () => { + const anomaly = new UnavailableAnomaly(); + expect(anomaly.message).toBe('Service is unavailable'); + }); + + it('should test all anomaly types with custom messages', () => { + const optionsWithMessage = { message: 'Custom test message' }; + const anomalies = [ + new UnavailableAnomaly(optionsWithMessage), + new InterruptedAnomaly(optionsWithMessage), + new BusyAnomaly(optionsWithMessage), + new IncorrectAnomaly(optionsWithMessage), + new ForbiddenAnomaly(optionsWithMessage), + new UnsupportedAnomaly(optionsWithMessage), + new NotFoundAnomaly(optionsWithMessage), + new ConflictAnomaly(optionsWithMessage), + new FaultAnomaly(optionsWithMessage), + ]; + + for (const anomaly of anomalies) { + expect(anomaly).toMatchObject(optionsWithMessage); + } + }); + + it('should allow overriding retryable for InterruptedAnomaly', () => { + const defaultInterrupted = new InterruptedAnomaly(); + expect(defaultInterrupted.retryable).toBe(false); + + const retryableInterrupted = new InterruptedAnomaly({ + message: 'Custom message', + retryable: true, + }); + expect(retryableInterrupted.retryable).toBe(true); + + const nonRetryableInterrupted = new InterruptedAnomaly({ + message: 'Custom message', + retryable: false, + }); + expect(nonRetryableInterrupted.retryable).toBe(false); + }); + + it('should allow overriding retryable for FaultAnomaly', () => { + const defaultFault = new FaultAnomaly(); + expect(defaultFault.retryable).toBe(false); + + const retryableFault = new FaultAnomaly({ + message: 'Custom message', + retryable: true, + }); + expect(retryableFault.retryable).toBe(true); + + const nonRetryableFault = new FaultAnomaly({ + message: 'Custom message', + retryable: false, + }); + expect(nonRetryableFault.retryable).toBe(false); + }); + }); +}); From 8ace8ead1d02446ac3c756c2450f3774ef469df0 Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Thu, 10 Jul 2025 10:13:05 +0100 Subject: [PATCH 2/2] Fixed potential bug in setting anomaly retryable state to false Shouldn't be an issue, since it's only possible to set this boolean when the anomaly is a "maybe retryable" one. In those cases, the default is false, so passing false will get the default. But it's still better to actually use the user-input instead of falling back to the `getRetryable` method. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/core/anomaly.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/anomaly.ts b/src/core/anomaly.ts index 115076f4a..78eca6ec8 100644 --- a/src/core/anomaly.ts +++ b/src/core/anomaly.ts @@ -34,7 +34,8 @@ class Anomaly extends Error { this.name = 'Anomaly'; this.category = category; - this.retryable = retryable || getRetryable(category); + this.retryable = + retryable !== undefined ? retryable : getRetryable(category); this.metadata = metadata; } }