- isRetryableStatus now fails closed for out-of-range values (600+, negative, NaN) by bounding the 5xx check on both sides (>= 500 && <= 599), instead of an unbounded >= 500 that classified garbage statuses like 1000 as retryable. - 408 Request Timeout is now retryable, matching the RPC_TRANSPORT doc comment (connection, timeout, 5xx) — a timeout surfaced as 408 is no longer treated differently from the same timeout surfaced as 504. - Add RPC_MALFORMED: the callee answered, but not with a ServiceResult (HTML error page, truncated body, unexpected shape). Distinct from RPC_TRANSPORT since something DID respond; non-retryable via the existing retryableFor, no new branch needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
RPC_ERROR_CODES,
|
|
ServiceError,
|
|
failure,
|
|
isRetryableStatus,
|
|
success,
|
|
} from "../src/errors.ts";
|
|
|
|
describe("rpc errors", () => {
|
|
test("success and failure build the result shape", () => {
|
|
expect(success(42)).toEqual({ ok: true, value: 42 });
|
|
const f = failure(RPC_ERROR_CODES.denied, "Forbidden");
|
|
expect(f.ok).toBe(false);
|
|
if (!f.ok) {
|
|
expect(f.code).toBe("RPC_DENIED");
|
|
expect(f.retryable).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("only transport failures are retryable", () => {
|
|
// 5xx and 429 are the callee saying "try again"; everything else is final.
|
|
expect(isRetryableStatus(500)).toBe(true);
|
|
expect(isRetryableStatus(503)).toBe(true);
|
|
expect(isRetryableStatus(429)).toBe(true);
|
|
expect(isRetryableStatus(599)).toBe(true);
|
|
expect(isRetryableStatus(408)).toBe(true);
|
|
expect(isRetryableStatus(400)).toBe(false);
|
|
expect(isRetryableStatus(403)).toBe(false);
|
|
expect(isRetryableStatus(404)).toBe(false);
|
|
expect(isRetryableStatus(409)).toBe(false);
|
|
expect(isRetryableStatus(200)).toBe(false);
|
|
});
|
|
|
|
test("an out-of-range status fails closed", () => {
|
|
for (const status of [600, 1000, 0, -1, Number.NaN]) {
|
|
expect(isRetryableStatus(status)).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("a malformed response is never retryable", () => {
|
|
const f = failure(RPC_ERROR_CODES.malformed, "Unexpected response shape");
|
|
expect(f.ok).toBe(false);
|
|
if (!f.ok) {
|
|
expect(f.code).toBe("RPC_MALFORMED");
|
|
expect(f.retryable).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("a denial is never retryable", () => {
|
|
const f = failure(RPC_ERROR_CODES.denied, "Forbidden");
|
|
if (!f.ok) expect(f.retryable).toBe(false);
|
|
});
|
|
|
|
test("ServiceError carries a code and does not leak a cause into its message", () => {
|
|
const error = new ServiceError(RPC_ERROR_CODES.handler, "Something failed");
|
|
expect(error.name).toBe("ServiceError");
|
|
expect(error.code).toBe("RPC_HANDLER");
|
|
expect(error.message).toBe("Something failed");
|
|
expect(error.retryable).toBe(false);
|
|
});
|
|
|
|
test("toResult produces an opaque failure", () => {
|
|
const error = new ServiceError(RPC_ERROR_CODES.handler, "db password is hunter2");
|
|
const result = error.toResult({ exposeMessage: false });
|
|
if (!result.ok) {
|
|
expect(result.message).toBe("Internal error");
|
|
expect(result.message).not.toContain("hunter2");
|
|
}
|
|
});
|
|
});
|