Initial Commit
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# `error-codes.ts` — webhook plugin error registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the ten stable `WEBHOOK_*` error codes raised by the api-plugin-webhook package and the concrete `WebhookError` class used to surface them. Every failure path in the plugin — invalid options, missing endpoints, exhausted retries, dead-letter writes, route-prefix collisions, missing `ServiceRegistry` slots, inactive endpoints, tenant mismatches, missing subscriptions, and persistence-layer failures — funnels through this module so consumers can branch on the typed `error.code` string without reaching for `instanceof` for individual subclasses.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable string codes registered through `@suaveplan/error`'s `defineCodes("WEBHOOK", [...])` (process-wide deduplication, OTel span-event compatible).
|
||||
- Concrete `WebhookError extends SuaveplanError<WebhookErrorCode>` so `instanceof WebhookError` narrows `code` to the typed union.
|
||||
- Re-exported under the package barrel and at the `./error-codes/error-codes` subpath for tree-shaking.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WEBHOOK_CODES, WebhookError } from "@suaveplan/api-plugin-webhook/error-codes/error-codes";
|
||||
|
||||
throw new WebhookError(
|
||||
WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
|
||||
"endpoint missing",
|
||||
{ context: { endpointId: "abc" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { WEBHOOK_CODES, WebhookError } from "@suaveplan/api-plugin-webhook";
|
||||
|
||||
try {
|
||||
await webhookService.send("missing", "order.created", {});
|
||||
} catch (err) {
|
||||
if (err instanceof WebhookError) {
|
||||
switch (err.webhookCode) {
|
||||
case WEBHOOK_CODES.ENDPOINT_NOT_FOUND:
|
||||
return ctx.json({ error: "unknown endpoint" }, 404);
|
||||
case WEBHOOK_CODES.DELIVERY_EXHAUSTED:
|
||||
return ctx.json({ error: "delivery failed, see dead letter" }, 502);
|
||||
case WEBHOOK_CODES.INACTIVE_ENDPOINT:
|
||||
return ctx.json({ error: "endpoint disabled" }, 409);
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WEBHOOK_CODES` — readonly string-literal map. Each property name is one of the ten registered codes and the value is the `"WEBHOOK_<NAME>"` string carried on `WebhookError.code`.
|
||||
- `WebhookErrorCode` — string-literal union derived from `typeof WEBHOOK_CODES[keyof typeof WEBHOOK_CODES]`.
|
||||
- `WebhookError` — extends `SuaveplanError<WebhookErrorCode>`. Constructor signature: `new WebhookError(code, message, options?)` where `options` accepts `{ cause?, context?, status?, retryable?, exposeMessage? }` via `SuaveplanErrorOptions`. Exposes `webhookCode` (alias for the typed `code`) so consumers can branch without a cast.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The ten codes were chosen to cover every distinct failure mode the plugin can surface to a caller: factory-level (`INVALID_INPUT`), service-resolution (`SERVICE_NOT_AVAILABLE`), route-registration (`ROUTE_PREFIX_COLLISION`), persistence (`STORE_ERROR`), authorization (`TENANT_MISMATCH`), state (`INACTIVE_ENDPOINT`, `ENDPOINT_NOT_FOUND`, `EVENT_NOT_SUBSCRIBED`), and delivery (`DELIVERY_EXHAUSTED`, `DEAD_LETTER_WRITE_FAILED`). Adding a new code requires a follow-up OpenSpec change so the spec-conformance test (`api-plugin-webhook.spec-conformance.test.ts`) stays in lock-step with the registry. The `defineCodes` factory throws on registry collision at module load — guarantees one canonical owner per code.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
WEBHOOK_CODES,
|
||||
WebhookError,
|
||||
type WebhookErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("WEBHOOK_CODES + WebhookError", () => {
|
||||
it("registers all ten declared codes with the WEBHOOK_ prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected: readonly WebhookErrorCode[] = [
|
||||
"WEBHOOK.INVALID_INPUT",
|
||||
"WEBHOOK.ENDPOINT_NOT_FOUND",
|
||||
"WEBHOOK.TENANT_MISMATCH",
|
||||
"WEBHOOK.EVENT_NOT_SUBSCRIBED",
|
||||
"WEBHOOK.DELIVERY_EXHAUSTED",
|
||||
"WEBHOOK.DEAD_LETTER_WRITE_FAILED",
|
||||
"WEBHOOK.STORE_ERROR",
|
||||
"WEBHOOK.SERVICE_NOT_AVAILABLE",
|
||||
"WEBHOOK.ROUTE_PREFIX_COLLISION",
|
||||
"WEBHOOK.INACTIVE_ENDPOINT",
|
||||
];
|
||||
const observed = Object.values(WEBHOOK_CODES).sort();
|
||||
expect(observed).toEqual([...expected].sort());
|
||||
});
|
||||
|
||||
it("constructs WebhookError with code, message, and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("downstream");
|
||||
const err = new WebhookError(
|
||||
WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
|
||||
"missing",
|
||||
{ cause, context: { endpointId: "abc" } }
|
||||
);
|
||||
expect(err).toBeInstanceOf(WebhookError);
|
||||
expect(err.code).toBe("WEBHOOK.ENDPOINT_NOT_FOUND");
|
||||
expect(err.webhookCode).toBe("WEBHOOK.ENDPOINT_NOT_FOUND");
|
||||
expect(err.context).toEqual({ endpointId: "abc" });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("narrows webhookCode via instanceof guard", { timeout: 5000 }, () => {
|
||||
const err: unknown = new WebhookError(
|
||||
WEBHOOK_CODES.DELIVERY_EXHAUSTED,
|
||||
"boom"
|
||||
);
|
||||
if (err instanceof WebhookError) {
|
||||
const code: WebhookErrorCode = err.webhookCode;
|
||||
expect(code).toBe("WEBHOOK.DELIVERY_EXHAUSTED");
|
||||
} else {
|
||||
throw new Error("guard failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-webhook`.
|
||||
*
|
||||
* Every failure path in the webhook plugin raises a {@link WebhookError}
|
||||
* carrying one of these ten `WEBHOOK.*` codes. Consumers branch on the
|
||||
* string value (e.g. `error.code === WEBHOOK_CODES.ENDPOINT_NOT_FOUND`).
|
||||
*
|
||||
* - `INVALID_INPUT` — `createWebhookPlugin(options)` received options
|
||||
* that failed `WebhookPluginOptionsSchema` validation. `cause` holds
|
||||
* the `ZodError`.
|
||||
* - `ENDPOINT_NOT_FOUND` — `EndpointStore.get(id)` returned `null` or
|
||||
* the resolved endpoint was filtered out by tenant scope. The
|
||||
* `context.endpointId` field carries the offending id.
|
||||
* - `TENANT_MISMATCH` — a write touched an endpoint owned by a
|
||||
* different tenant than the requesting principal. `context.expected`
|
||||
* and `context.actual` carry the tenant ids.
|
||||
* - `EVENT_NOT_SUBSCRIBED` — `WebhookService.sendAll(event)` was called
|
||||
* but no active endpoint listed that event in its subscriptions.
|
||||
* - `DELIVERY_EXHAUSTED` — every retry attempt failed; the dead-letter
|
||||
* sink has the full attempt history. The original webhook-client
|
||||
* failure is carried as `cause`.
|
||||
* - `DEAD_LETTER_WRITE_FAILED` — `DeadLetterSink.record` threw after a
|
||||
* delivery exhausted. The sink error is carried as `cause` while the
|
||||
* triggering delivery failure travels on the chain.
|
||||
* - `STORE_ERROR` — `EndpointStore` operation rejected (custom store
|
||||
* implementations only — `MemoryEndpointStore` cannot raise this).
|
||||
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was
|
||||
* absent at `onInit`. `context.missing` lists the missing slot names.
|
||||
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
|
||||
* with routes already registered by another plugin in the host's
|
||||
* `OpenAPIRegistry`.
|
||||
* - `INACTIVE_ENDPOINT` — `WebhookService.send` was called against an
|
||||
* endpoint whose `active === false`. No delivery is attempted.
|
||||
*/
|
||||
export const WEBHOOK_CODES = defineCodes("WEBHOOK", [
|
||||
"INVALID_INPUT",
|
||||
"ENDPOINT_NOT_FOUND",
|
||||
"TENANT_MISMATCH",
|
||||
"EVENT_NOT_SUBSCRIBED",
|
||||
"DELIVERY_EXHAUSTED",
|
||||
"DEAD_LETTER_WRITE_FAILED",
|
||||
"STORE_ERROR",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
"INACTIVE_ENDPOINT",
|
||||
] as const);
|
||||
|
||||
export type WebhookErrorCode =
|
||||
(typeof WEBHOOK_CODES)[keyof typeof WEBHOOK_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-plugin-webhook package. Extends
|
||||
* {@link SuaveplanError} so `instanceof WebhookError` narrows `code` to
|
||||
* {@link WebhookErrorCode}.
|
||||
*/
|
||||
export class WebhookError extends SuaveplanError<WebhookErrorCode> {
|
||||
public readonly webhookCode: WebhookErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: WebhookErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.webhookCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { WebhookErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
WEBHOOK_CODES,
|
||||
WebhookError,
|
||||
} from "./error-codes.js";
|
||||
Reference in New Issue
Block a user