feat(iios): idempotency ledger on outbound egress (409 on key reuse) (P9)
OutboundService.send is now opt-in idempotent: an explicit idempotencyKey routes the send through IdempotencyService.run (Slice 6), so reusing a key with a different target/payload returns a 409 conflict and a same-key/same-request retry replays the cached command. The inner findUnique early-return stays as a backstop so a FAILED-then-retried send never collides on the command's unique key. Keyless sends keep the old behavior (no ledger row). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CapabilityBroker } from '../capability/capability.broker';
|
||||
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||
import { recordAudit } from '../observability/audit';
|
||||
|
||||
/**
|
||||
@@ -23,55 +24,67 @@ export class OutboundService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly broker: CapabilityBroker,
|
||||
private readonly idempotency: IdempotencyService,
|
||||
) {}
|
||||
|
||||
async send(
|
||||
channelType: string,
|
||||
target: string,
|
||||
payload: Record<string, unknown>,
|
||||
idempotencyKey: string = randomUUID(),
|
||||
idempotencyKey?: string,
|
||||
scopeId?: string,
|
||||
purpose?: string,
|
||||
) {
|
||||
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } });
|
||||
if (existing) return existing;
|
||||
const key = idempotencyKey ?? randomUUID();
|
||||
|
||||
// The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried
|
||||
// command returns the existing row instead of colliding on idempotencyKey @unique.
|
||||
const body = async () => {
|
||||
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey: key } });
|
||||
if (existing) return existing;
|
||||
|
||||
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
||||
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key },
|
||||
});
|
||||
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
||||
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey },
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key },
|
||||
});
|
||||
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||
return cmd;
|
||||
}
|
||||
|
||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey },
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey: key, scopeId, purpose });
|
||||
} catch (err) {
|
||||
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }),
|
||||
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }),
|
||||
]);
|
||||
throw err;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId, purpose });
|
||||
} catch (err) {
|
||||
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }),
|
||||
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }),
|
||||
const [updated] = await this.prisma.$transaction([
|
||||
this.prisma.iiosOutboundCommand.update({
|
||||
where: { id: cmd.id },
|
||||
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
|
||||
}),
|
||||
this.prisma.iiosDeliveryAttempt.create({
|
||||
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
|
||||
}),
|
||||
]);
|
||||
throw err;
|
||||
}
|
||||
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
|
||||
return updated;
|
||||
};
|
||||
|
||||
const [updated] = await this.prisma.$transaction([
|
||||
this.prisma.iiosOutboundCommand.update({
|
||||
where: { id: cmd.id },
|
||||
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
|
||||
}),
|
||||
this.prisma.iiosDeliveryAttempt.create({
|
||||
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
|
||||
}),
|
||||
]);
|
||||
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
|
||||
return updated;
|
||||
// Opt-in idempotency: an explicit key routes through the ledger (request-hash conflict → 409,
|
||||
// response replay); a keyless send just runs with a fresh uuid (old behavior).
|
||||
if (!idempotencyKey) return body();
|
||||
return this.idempotency.run({ scopeId, commandName: 'channel.send', key: idempotencyKey, request: { channelType, target, payload, purpose } }, body);
|
||||
}
|
||||
|
||||
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
|
||||
|
||||
Reference in New Issue
Block a user