Skip to content
Merged
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
163 changes: 144 additions & 19 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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) {
Expand All @@ -89,7 +201,14 @@ export class TypeMismatchError extends DecreeError {
}
}

const STATUS_MAP: ReadonlyMap<number, new (msg: string, code: number) => 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],
Expand All @@ -98,14 +217,20 @@ const STATUS_MAP: ReadonlyMap<number, new (msg: string, code: number) => 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. */
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 });
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
90 changes: 89 additions & 1 deletion test/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
});