@beignet/core
Version:
Core framework primitives for Beignet
345 lines (318 loc) • 9.62 kB
text/typescript
/**
* Idempotency hooks for @beignet/core/server
*/
import {
type ErrorReporterPort,
tryReportException,
} from "../../error-reporting/index.js";
import { AppError, httpErrors } from "../../errors/index.js";
import {
createIdempotencyFingerprint,
IdempotencyConflictError,
IdempotencyInProgressError,
type IdempotencyMeta,
type IdempotencyPort,
type IdempotencyScope,
} from "../../idempotency/index.js";
import {
type ActivityActor,
type ActivityTenant,
AuthUnauthorizedError,
TenantRequiredError,
} from "../../ports/index.js";
import {
type ResponseFinalizerServerHook,
responseFinalizerHook,
} from "../internal-hooks.js";
import { finalizeResponse } from "../response-finalization.js";
import type {
HttpRequestLike,
HttpResponseLike,
ServerHook,
} from "../types.js";
/**
* Ports required by idempotency hooks.
*/
export type IdempotencyPorts = {
idempotency: IdempotencyPort;
};
type OptionalErrorReporterPorts = IdempotencyPorts & {
errorReporter?: ErrorReporterPort;
};
/**
* Minimal context shape required for actor- and tenant-scoped idempotency.
*/
export type CtxWithIdempotency = {
ports: IdempotencyPorts;
actor?: ActivityActor;
tenant?: ActivityTenant;
};
/**
* Options for `createIdempotencyHooks(...)`.
*/
export interface IdempotencyHooksOptions<Ctx> {
/**
* Build the idempotency namespace for a contract.
*
* Defaults to `http.<contract name>` so HTTP reservations never collide with
* use-case `runIdempotently(...)` namespaces.
*/
namespace?: (args: { contract: { name: string } }) => string;
/**
* Build the idempotency scope after context exists.
*
* Defaults to a scope derived from `meta.scope`: omitted metadata scopes by
* `ctx.actor?.id` and also `ctx.tenant?.id` when present, `"actor"` scopes by
* the actor only, `"global"` stays global, `"tenant"` scopes by the tenant,
* and `"actor-tenant"` scopes by both.
*/
scope?: (args: {
ctx: Ctx;
req: HttpRequestLike;
meta: IdempotencyMeta;
}) => IdempotencyScope;
/**
* Build the fingerprint input from the parsed request.
*
* Defaults to `{ path, query, body }`.
*/
fingerprintInput?: (args: {
path: unknown;
query: unknown;
body: unknown;
}) => unknown;
}
/**
* Header set on replayed responses.
*/
const IDEMPOTENCY_REPLAYED_HEADER = "idempotency-replayed";
type PendingReservation = {
port: IdempotencyPort;
namespace: string;
key: string;
scope: IdempotencyScope;
fingerprint: string;
reservationToken: string;
};
function defaultIdempotencyScope(
ctx: CtxWithIdempotency,
meta: IdempotencyMeta,
): IdempotencyScope {
const mode = meta.scope ?? (ctx.tenant?.id ? "actor-tenant" : "actor");
switch (mode) {
case "global":
return "global";
case "actor":
if (!ctx.actor?.id) throw new AuthUnauthorizedError();
return { actorId: ctx.actor.id };
case "tenant":
if (!ctx.tenant?.id) throw new TenantRequiredError();
return { tenantId: ctx.tenant.id };
case "actor-tenant":
if (!ctx.actor?.id) throw new AuthUnauthorizedError();
if (!ctx.tenant?.id) throw new TenantRequiredError();
return { actorId: ctx.actor.id, tenantId: ctx.tenant.id };
}
}
function isReplayableHttpResponse(value: unknown): value is HttpResponseLike {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { status?: unknown; headers?: unknown };
if (typeof candidate.status !== "number") return false;
if (
candidate.headers !== undefined &&
(typeof candidate.headers !== "object" ||
candidate.headers === null ||
Array.isArray(candidate.headers))
) {
return false;
}
return true;
}
/**
* Create metadata-driven idempotency hooks.
*
* The hook reads `contract.metadata.idempotency` and enforces it with
* `ctx.ports.idempotency`. In `beforeHandle` it reserves the client key after
* request parsing and route hook identity resolution, replays completed matching
* responses with an `idempotency-replayed: true` header, and rejects in-progress
* or conflicting keys with the framework
* `IdempotencyInProgress`/`IdempotencyConflict` catalog errors. After the
* response-validation phase, it stores final route-owned 2xx framework-neutral
* responses for replay and releases the reservation for framework-owned
* responses, errors, non-2xx responses, and native `Response` results, which
* are not replayable.
*
* Use `runIdempotently(...)` from `@beignet/core/idempotency` for non-HTTP
* workflows such as jobs, listeners, webhooks, and schedules.
*
* @param options - Optional namespace, scope, and fingerprint-input builders.
* @returns A server hook backed by `ctx.ports.idempotency`.
*/
export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
options: IdempotencyHooksOptions<Ctx> = {},
): ServerHook<Ctx, IdempotencyPorts> {
const pending = new WeakMap<HttpRequestLike, PendingReservation>();
const hooks: ServerHook<Ctx, IdempotencyPorts> &
ResponseFinalizerServerHook<Ctx> = {
name: "idempotency",
beforeHandle: async ({ ctx, contract, req, path, query, body }) => {
const meta = contract.metadata?.idempotency;
if (!meta) {
return undefined;
}
const header = (meta.header ?? "idempotency-key").toLowerCase();
const key = req.headers.get(header);
if (!key) {
if (meta.required) {
throw new AppError(
httpErrors.BadRequest,
{
contract: contract.name,
header,
},
`Missing required idempotency key header "${header}"`,
);
}
return undefined;
}
const namespace =
options.namespace?.({ contract: { name: contract.name } }) ??
`http.${contract.name}`;
const scope =
options.scope?.({ ctx, req, meta }) ??
defaultIdempotencyScope(ctx, meta);
const fingerprint = await createIdempotencyFingerprint(
options.fingerprintInput?.({ path, query, body }) ?? {
path,
query,
body,
},
);
const reservation = await ctx.ports.idempotency.reserve({
namespace,
key,
scope,
fingerprint,
ttlSec: meta.ttlSec,
reservationTtlSec: meta.reservationTtlSec,
});
switch (reservation.status) {
case "replay": {
if (!isReplayableHttpResponse(reservation.result)) {
throw new AppError(
httpErrors.InternalServerError,
{ namespace, key },
`Stored idempotency result for "${namespace}" key "${key}" is not a replayable HTTP response`,
);
}
const validated = await finalizeResponse(
contract,
reservation.result,
);
return {
status: validated.status,
headers: {
...(validated.headers ?? {}),
[IDEMPOTENCY_REPLAYED_HEADER]: "true",
},
body: validated.body,
};
}
case "inProgress": {
throw new IdempotencyInProgressError(reservation);
}
case "conflict": {
throw new IdempotencyConflictError(reservation);
}
case "reserved": {
pending.set(req, {
port: ctx.ports.idempotency,
namespace,
key,
scope,
fingerprint,
reservationToken: reservation.reservationToken,
});
return undefined;
}
}
},
[responseFinalizerHook]: async ({
req,
ctx,
response,
error,
native,
owner,
responseValidation,
}) => {
const reservation = pending.get(req);
if (!reservation) {
return undefined;
}
pending.delete(req);
const { port, namespace, key, scope, fingerprint, reservationToken } =
reservation;
if (
owner === "route" &&
responseValidation === "validated" &&
!native &&
!error &&
response.status >= 200 &&
response.status < 300
) {
await port.complete({
namespace,
key,
scope,
fingerprint,
reservationToken,
result: {
status: response.status,
headers: response.headers,
body: response.body,
},
});
return undefined;
}
// Framework-owned responses, errors, non-2xx responses, and native
// `Response` results release the reservation. Streams are not replayable.
try {
await port.fail({
namespace,
key,
scope,
fingerprint,
reservationToken,
error,
});
} catch (settlementError) {
if (error === undefined) {
throw settlementError;
}
await tryReportException({
reporter: ctx
? (ctx.ports as OptionalErrorReporterPorts).errorReporter
: undefined,
error: settlementError,
reportOptions: {
level: "error",
mechanism: "beignet.idempotency.settlement",
handled: true,
tags: {
"idempotency.namespace": namespace,
},
contexts: {
idempotency: {
namespace,
key,
},
},
},
});
}
return undefined;
},
};
return hooks;
}