Skip to content

Errors become escalations

The escalation boundaryDashboardHTTPSDKMCPconditionaldidInterruptproxyActivities

When retries run out, the failure becomes a row an operator can act on. The run waits for retry or skip instead of disappearing.

The situation

A payment capture fails after its retry budget. The order should neither be dropped nor retried forever. Someone in operations should see the error, decide, and the workflow should continue from that decision.

The recipe

workflows/capture-order.ts
import { Durable } from '@hotmeshio/hotmesh';
import { conditional, type LTEnvelope } from '@hotmeshio/long-tail';
import * as activities from './activities';
const { capturePayment } = Durable.workflow.proxyActivities<typeof activities>({
activities,
retry: { maximumAttempts: 3, backoffCoefficient: 2, maximumInterval: '30s' },
});
export async function captureOrder(envelope: LTEnvelope) {
const { orderId } = envelope.data;
const { workflowId } = Durable.workflow.workflowInfo();
for (let attempt = 1; ; attempt++) {
try {
return { type: 'return' as const, data: await capturePayment(orderId) };
} catch (err) {
if (Durable.workflow.didInterrupt(err as Error)) throw err;
const next = await conditional<{ action: 'retry' | 'skip' }>(`capture-error-${workflowId}-${attempt}`, {
role: 'ops',
subtype: 'error',
description: `Capture failed: ${(err as Error).message}`,
metadata: { orderId, attempt },
envelope: { error: (err as Error).message },
});
if (!next || next.action === 'skip') return { type: 'return' as const, data: { orderId, skipped: true } };
}
}
}

didInterrupt passes the engine’s own control-flow signals through. Every real failure parks one ops row with the message, and each attempt gets its own deterministic signal id.

The guarantee

A failure is a pending row with its error attached, searchable by orderId and subtype. See Workflows.