From 50b4cca9a94e7bb95197c658bcebb364b5983690 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 24 May 2026 15:22:23 +0300 Subject: [PATCH] feat(errors): add cause chaining, 6 typed error classes, and toJSON Pass { cause: err } in mapGrpcError so the original gRPC ServiceError is preserved in the error chain for logging and debugging. Add ResourceExhaustedError, DataLossError, OutOfRangeError, CancelledError, UnimplementedError, and DeadlineExceededError, wiring each into STATUS_MAP and exporting from src/index.ts. Add toJSON() on DecreeError so logging frameworks retain name, code, and message as structured fields on every error subclass. Expand test/errors.test.ts to cover new typed classes, cause chaining, and toJSON behavior. Co-Authored-By: Claude Closes #52 --- src/errors.ts | 163 ++++++++++++++++++++++++++++++++++++++------ src/index.ts | 6 ++ test/errors.test.ts | 90 +++++++++++++++++++++++- 3 files changed, 239 insertions(+), 20 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 2cf91fa..19ec980 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -10,69 +10,181 @@ import { type ServiceError, status } from "@grpc/grpc-js"; export class DecreeError extends Error { readonly code?: (typeof status)[keyof typeof status]; - constructor(message: string, code?: (typeof status)[keyof typeof status]) { - super(message); + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, options); this.name = "DecreeError"; this.code = code; } + + toJSON(): Record { + return { + name: this.name, + message: this.message, + code: this.code, + }; + } } /** Raised when a requested resource does not exist. */ export class NotFoundError extends DecreeError { - constructor(message: string, code?: (typeof status)[keyof typeof status]) { - super(message, code); + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); this.name = "NotFoundError"; } } /** Raised when attempting to create a resource that already exists. */ export class AlreadyExistsError extends DecreeError { - constructor(message: string, code?: (typeof status)[keyof typeof status]) { - super(message, code); + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); this.name = "AlreadyExistsError"; } } /** Raised when a request contains invalid arguments. */ export class InvalidArgumentError extends DecreeError { - constructor(message: string, code?: (typeof status)[keyof typeof status]) { - super(message, code); + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); this.name = "InvalidArgumentError"; } } /** Raised when a field is locked and cannot be modified. */ export class LockedError extends DecreeError { - constructor(message: string, code?: (typeof status)[keyof typeof status]) { - super(message, code); + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); this.name = "LockedError"; } } /** Raised when an optimistic concurrency check fails. */ export class ChecksumMismatchError extends DecreeError { - constructor(message: string, code?: (typeof status)[keyof typeof status]) { - super(message, code); + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); this.name = "ChecksumMismatchError"; } } /** Raised when the caller lacks permission for the operation. */ export class PermissionDeniedError extends DecreeError { - constructor(message: string, code?: (typeof status)[keyof typeof status]) { - super(message, code); + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); this.name = "PermissionDeniedError"; } } /** Raised when the server is unavailable. */ export class UnavailableError extends DecreeError { - constructor(message: string, code?: (typeof status)[keyof typeof status]) { - super(message, code); + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); this.name = "UnavailableError"; } } +/** Raised when a quota or rate limit has been exceeded. */ +export class ResourceExhaustedError extends DecreeError { + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); + this.name = "ResourceExhaustedError"; + } +} + +/** Raised when unrecoverable data loss or corruption is detected. */ +export class DataLossError extends DecreeError { + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); + this.name = "DataLossError"; + } +} + +/** Raised when a value is out of the valid range. */ +export class OutOfRangeError extends DecreeError { + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); + this.name = "OutOfRangeError"; + } +} + +/** Raised when an operation was cancelled by the caller. */ +export class CancelledError extends DecreeError { + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); + this.name = "CancelledError"; + } +} + +/** Raised when an operation is not implemented by the server. */ +export class UnimplementedError extends DecreeError { + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); + this.name = "UnimplementedError"; + } +} + +/** Raised when a deadline expired before the operation completed. */ +export class DeadlineExceededError extends DecreeError { + constructor( + message: string, + code?: (typeof status)[keyof typeof status], + options?: ErrorOptions, + ) { + super(message, code, options); + this.name = "DeadlineExceededError"; + } +} + /** Raised when the server version is incompatible with this SDK. */ export class IncompatibleServerError extends DecreeError { constructor(message: string) { @@ -89,7 +201,14 @@ export class TypeMismatchError extends DecreeError { } } -const STATUS_MAP: ReadonlyMap DecreeError> = new Map([ +const STATUS_MAP: ReadonlyMap< + number, + new ( + msg: string, + code: number, + opts: ErrorOptions, + ) => DecreeError +> = new Map([ [status.NOT_FOUND, NotFoundError], [status.ALREADY_EXISTS, AlreadyExistsError], [status.INVALID_ARGUMENT, InvalidArgumentError], @@ -98,6 +217,12 @@ const STATUS_MAP: ReadonlyMap DecreeE [status.PERMISSION_DENIED, PermissionDeniedError], [status.UNAUTHENTICATED, PermissionDeniedError], [status.UNAVAILABLE, UnavailableError], + [status.RESOURCE_EXHAUSTED, ResourceExhaustedError], + [status.DATA_LOSS, DataLossError], + [status.OUT_OF_RANGE, OutOfRangeError], + [status.CANCELLED, CancelledError], + [status.UNIMPLEMENTED, UnimplementedError], + [status.DEADLINE_EXCEEDED, DeadlineExceededError], ]); /** Convert a gRPC ServiceError to a typed DecreeError. */ @@ -105,7 +230,7 @@ export function mapGrpcError(err: ServiceError): DecreeError { const ErrorClass = STATUS_MAP.get(err.code); const message = err.details || err.message; if (ErrorClass) { - return new ErrorClass(message, err.code); + return new ErrorClass(message, err.code, { cause: err }); } - return new DecreeError(message, err.code); + return new DecreeError(message, err.code, { cause: err }); } diff --git a/src/index.ts b/src/index.ts index 80c7158..cadb252 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,16 +17,22 @@ export { convertValue, typedValueToString } from "./convert.js"; // Error hierarchy export { AlreadyExistsError, + CancelledError, ChecksumMismatchError, + DataLossError, + DeadlineExceededError, DecreeError, IncompatibleServerError, InvalidArgumentError, LockedError, mapGrpcError, NotFoundError, + OutOfRangeError, PermissionDeniedError, + ResourceExhaustedError, TypeMismatchError, UnavailableError, + UnimplementedError, } from "./errors.js"; // Utilities (re-export for advanced users) export { withRetry } from "./retry.js"; diff --git a/test/errors.test.ts b/test/errors.test.ts index 66401ef..5ffb5f6 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -2,16 +2,22 @@ import { Metadata, type ServiceError, status } from "@grpc/grpc-js"; import { describe, expect, it } from "vitest"; import { AlreadyExistsError, + CancelledError, ChecksumMismatchError, + DataLossError, + DeadlineExceededError, DecreeError, IncompatibleServerError, InvalidArgumentError, LockedError, mapGrpcError, NotFoundError, + OutOfRangeError, PermissionDeniedError, + ResourceExhaustedError, TypeMismatchError, UnavailableError, + UnimplementedError, } from "../src/errors.js"; function makeServiceError(code: number, details: string): ServiceError { @@ -41,7 +47,22 @@ describe("error hierarchy", () => { expect(err.code).toBeUndefined(); }); - it("subclasses extend DecreeError", () => { + it("DecreeError toJSON includes name, message, code", () => { + const err = new DecreeError("msg", status.INTERNAL); + expect(err.toJSON()).toEqual({ name: "DecreeError", message: "msg", code: status.INTERNAL }); + }); + + it("DecreeError toJSON code is undefined when not set", () => { + const err = new DecreeError("msg"); + expect(err.toJSON()).toEqual({ name: "DecreeError", message: "msg", code: undefined }); + }); + + it("subclass toJSON reflects subclass name", () => { + const err = new NotFoundError("gone", status.NOT_FOUND); + expect(err.toJSON()).toMatchObject({ name: "NotFoundError", code: status.NOT_FOUND }); + }); + + it("original subclasses extend DecreeError", () => { const classes = [ NotFoundError, AlreadyExistsError, @@ -61,6 +82,25 @@ describe("error hierarchy", () => { } }); + it("new typed subclasses extend DecreeError", () => { + const classes = [ + ResourceExhaustedError, + DataLossError, + OutOfRangeError, + CancelledError, + UnimplementedError, + DeadlineExceededError, + ] as const; + + for (const Cls of classes) { + const err = new Cls("msg", status.UNKNOWN); + expect(err).toBeInstanceOf(DecreeError); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("msg"); + expect(err.code).toBe(status.UNKNOWN); + } + }); + it("IncompatibleServerError has no code", () => { const err = new IncompatibleServerError("bad version"); expect(err).toBeInstanceOf(DecreeError); @@ -119,6 +159,42 @@ describe("mapGrpcError", () => { expect(err).toBeInstanceOf(UnavailableError); }); + it("maps RESOURCE_EXHAUSTED to ResourceExhaustedError", () => { + const err = mapGrpcError(makeServiceError(status.RESOURCE_EXHAUSTED, "quota exceeded")); + expect(err).toBeInstanceOf(ResourceExhaustedError); + expect(err.code).toBe(status.RESOURCE_EXHAUSTED); + }); + + it("maps DATA_LOSS to DataLossError", () => { + const err = mapGrpcError(makeServiceError(status.DATA_LOSS, "data lost")); + expect(err).toBeInstanceOf(DataLossError); + expect(err.code).toBe(status.DATA_LOSS); + }); + + it("maps OUT_OF_RANGE to OutOfRangeError", () => { + const err = mapGrpcError(makeServiceError(status.OUT_OF_RANGE, "out of range")); + expect(err).toBeInstanceOf(OutOfRangeError); + expect(err.code).toBe(status.OUT_OF_RANGE); + }); + + it("maps CANCELLED to CancelledError", () => { + const err = mapGrpcError(makeServiceError(status.CANCELLED, "cancelled")); + expect(err).toBeInstanceOf(CancelledError); + expect(err.code).toBe(status.CANCELLED); + }); + + it("maps UNIMPLEMENTED to UnimplementedError", () => { + const err = mapGrpcError(makeServiceError(status.UNIMPLEMENTED, "not implemented")); + expect(err).toBeInstanceOf(UnimplementedError); + expect(err.code).toBe(status.UNIMPLEMENTED); + }); + + it("maps DEADLINE_EXCEEDED to DeadlineExceededError", () => { + const err = mapGrpcError(makeServiceError(status.DEADLINE_EXCEEDED, "deadline exceeded")); + expect(err).toBeInstanceOf(DeadlineExceededError); + expect(err.code).toBe(status.DEADLINE_EXCEEDED); + }); + it("maps unknown codes to generic DecreeError", () => { const err = mapGrpcError(makeServiceError(status.INTERNAL, "internal error")); expect(err).toBeInstanceOf(DecreeError); @@ -136,4 +212,16 @@ describe("mapGrpcError", () => { noDetails.metadata = new Metadata(); expect(mapGrpcError(noDetails).message).toBe("fallback"); }); + + it("chains cause from original gRPC error", () => { + const grpcErr = makeServiceError(status.NOT_FOUND, "not found"); + const err = mapGrpcError(grpcErr); + expect((err as Error & { cause: unknown }).cause).toBe(grpcErr); + }); + + it("generic DecreeError also chains cause", () => { + const grpcErr = makeServiceError(status.INTERNAL, "internal"); + const err = mapGrpcError(grpcErr); + expect((err as Error & { cause: unknown }).cause).toBe(grpcErr); + }); });