3.6 KiB
dead-letter.ts — DeadLetterSink interface
(no submodules)
Purpose
Defines the DeadLetterSink interface and DeadLetterEntry shape used by WebhookService.send to persist delivery attempts that exhausted every retry. Operators rely on the sink to replay or diagnose failed deliveries — the full attempts history (status codes, durations, error messages) survives the failure so a webhook can be reconstructed exactly. The interface ships with a default in-memory implementation (MemoryDeadLetterSink) and is the plug point for Mailpit / Slack / S3 / Postgres siblings.
Features
DeadLetterEntry— JSON-serialisable; carries the originating endpoint id, tenant id, event name, full payload, every attempt record, first + final attempt timestamps, and a human-readable reason.ListDeadLettersFilter—{ endpointId?, limit? }. Implementers SHOULD return most-recent first whenlimitis supplied.- Two-method interface (
record,list) — small enough for sinks to author trivially, large enough to cover the inspection use case the audit calls for.
Usage: Basic
import type { DeadLetterSink } from "@suaveplan/api-plugin-webhook/dead-letter/dead-letter";
declare const sink: DeadLetterSink;
const failed = await sink.list({ limit: 50 });
console.log(failed.length);
Usage: Advanced
import type {
DeadLetterEntry,
DeadLetterSink,
ListDeadLettersFilter,
} from "@suaveplan/api-plugin-webhook/dead-letter/dead-letter";
class S3DeadLetterSink implements DeadLetterSink {
constructor(private readonly bucket: string) {}
async record(entry: DeadLetterEntry): Promise<void> {
await s3PutObject({
Bucket: this.bucket,
Key: `dead-letter/${entry.endpointId}/${entry.id}.json`,
Body: JSON.stringify(entry),
ContentType: "application/json",
});
}
async list(filter: ListDeadLettersFilter = {}): Promise<readonly DeadLetterEntry[]> {
const objects = await s3ListObjects({
Bucket: this.bucket,
Prefix: filter.endpointId
? `dead-letter/${filter.endpointId}/`
: "dead-letter/",
MaxKeys: filter.limit ?? 1000,
});
return Promise.all(objects.map((o) => s3GetObjectJson<DeadLetterEntry>(o.Key)));
}
}
API Reference
DeadLetterEntry— readonly record.idis sink-generated;endpointId/tenantId/event/payload/attempts/firstAttemptAt/finalAttemptAt/reasonare populated by the service when a delivery exhausts.ListDeadLettersFilter—{ endpointId?: string; limit?: number }. Both fields optional.DeadLetterSink.record(entry)— durably stores the entry. Returns once the write commits; throws on persistence failure (the service translates this toWEBHOOK_DEAD_LETTER_WRITE_FAILED).DeadLetterSink.list(filter?)— returns recorded entries matching the filter, most-recent first whenlimitis supplied.
Implementation Notes
record errors are surfaced separately from the delivery failure (the service raises WEBHOOK_DEAD_LETTER_WRITE_FAILED with the sink error as cause and the original delivery exhaustion preserved on the cause chain). This split lets operators distinguish "the receiver is broken" from "the sink is broken" in their dashboards. The sink interface does not provide a purge method on purpose — purging policy belongs to operators (cron jobs, lifecycle policies, manual tooling), not to the recording surface. Sinks SHOULD treat DeadLetterEntry.payload as opaque; the plugin's WebhookPayload.data field is unknown and may carry caller-supplied PII.