Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/core/anomaly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
type AnomalyCategory =
| 'unavailable'
| 'interrupted'
| 'busy'
| 'incorrect'
| 'forbidden'
| 'unsupported'
| 'not-found'
| 'conflict'
| 'fault';

type AnomalyOptions = {
message?: string;
metadata?: Record<string, unknown>;
};

type MaybeRetryableAnomalyOptions = AnomalyOptions & {
retryable?: boolean;
};

class Anomaly extends Error {
public readonly category: AnomalyCategory;
public readonly retryable: boolean;
public readonly metadata?: Record<string, unknown>;

constructor(
category: AnomalyCategory,
message?: string,
retryable?: boolean,
metadata?: Record<string, unknown>,
) {
const defaultMessage = getDefaultMessage(category);
super(message || defaultMessage);

this.name = 'Anomaly';
this.category = category;
this.retryable =
retryable !== undefined ? 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;
}
}
152 changes: 152 additions & 0 deletions src/core/anomaly.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});