Error Handling & Rollback (.onError)
Production workflows interact with unreliable external systems, rate-limited APIs, and bounded review loops that can fail mid-flight. AGBoss makes failure recovery explicit in HarnessDSL (HDSL) @Workflow({ flow }) chains via .onError(ErrorClass, sidePath) on individual steps and .onJoinError(ErrorClass, sidePath) on parallel joins, keeping compensation logic type-checked and unit-tested alongside the happy path.
How .onError() chains compensation steps in a workflow path
Section titled “How .onError() chains compensation steps in a workflow path”Instead of hidden automatic rollbacks that guess how to undo a side effect, you attach .onError(ErrorClass, sidePath) directly after the @Action, @Agent, or @Router that can throw. The compensation side path has full Path Scope access to DTOs produced by earlier steps (such as a StockReservationDto needed to release reserved inventory):
import { Workflow, start, toAction } from "@agboss/core";
@Workflow({ name: "order-workflow", version: "1.0.0", flow: [ start(OrderWorkflowStart) .returns(OrderRequestDto) .toAction(ReserveStockAction) .returns(StockReservationDto) .toAction(ShipOrderAction) .onError( CarrierError, toAction(RefundCardAction) .onError( PaymentProviderError, toAction(NotifyFinanceTeamAction) .toFinish(OrderFailedWorkflowFinish), ) .toAction(ReleaseStockAction) .toFinish(OrderFailedWorkflowFinish), ) .returns(OrderShippedDto) .toFinish(OrderWorkflowFinish), ],})export class OrderWorkflow {}import { Action, WorkflowFinish, WorkflowStart, type ActionHandler, type InputOf, type OutputOf } from "@agboss/core";import { Text } from "@agboss/core/dto";
export class OrderRequestDto { @Text({ prompt: "order id" }) orderId!: string;}export class StockReservationDto { @Text({ prompt: "reservation id" }) reservationId!: string;}export class OrderShippedDto { @Text({ prompt: "tracking number" }) trackingNumber!: string;}export class CarrierError extends Error {}export class PaymentProviderError extends Error {}
@WorkflowStart({ name: "order-workflow-start", description: "Order request", input: OrderRequestDto })export class OrderWorkflowStart {}
@WorkflowFinish({ name: "order-workflow-finish", description: "Order shipped", output: OrderShippedDto })export class OrderWorkflowFinish {}
@WorkflowFinish({ name: "order-failed-workflow-finish", description: "Order failed", output: OrderRequestDto })export class OrderFailedWorkflowFinish {}
@Action({ name: "reserve-stock-action", description: "Reserves stock", input: { order: OrderRequestDto }, output: StockReservationDto,})export class ReserveStockAction implements ActionHandler<ReserveStockAction> { execute(input: InputOf<ReserveStockAction>): Promise<OutputOf<ReserveStockAction>> { return Promise.resolve({ reservationId: `res-${input.order.value.orderId}` }); }}
@Action({ name: "ship-order-action", description: "Books carrier shipment", input: { reservation: StockReservationDto }, output: OrderShippedDto,})export class ShipOrderAction implements ActionHandler<ShipOrderAction> { execute(input: InputOf<ShipOrderAction>): Promise<OutputOf<ShipOrderAction>> { return Promise.resolve({ trackingNumber: `trk-${input.reservation.value.reservationId}` }); }}
@Action({ name: "refund-card-action", description: "Refunds card charge", input: { order: OrderRequestDto },})export class RefundCardAction implements ActionHandler<RefundCardAction> { execute(_input: InputOf<RefundCardAction>): Promise<void> { return Promise.resolve(); }}
@Action({ name: "release-stock-action", description: "Releases reserved stock", input: { reservation: StockReservationDto },})export class ReleaseStockAction implements ActionHandler<ReleaseStockAction> { execute(_input: InputOf<ReleaseStockAction>): Promise<void> { return Promise.resolve(); }}
@Action({ name: "notify-finance-team-action", description: "Alerts finance when refund fails", input: { order: OrderRequestDto },})export class NotifyFinanceTeamAction implements ActionHandler<NotifyFinanceTeamAction> { execute(_input: InputOf<NotifyFinanceTeamAction>): Promise<void> { return Promise.resolve(); }}import { Application, EmptyEnvironment } from "@agboss/core";import { expect } from "vitest";import { testWith } from "@agboss/core/testing";import { OrderWorkflow } from "./order.workflow.js";import { OrderWorkflowFinish, OrderWorkflowStart } from "./order.components.js";
@Application({ name: "order-application", version: "1.0.0", environment: EmptyEnvironment, workflows: [OrderWorkflow], budgets: [OrderAppBudget, OrderTenantBudget, OrderExecBudget],})export class OrderApplication {}
const test = testWith(OrderApplication);
test("reserves stock and ships order on the happy path", async ({ app }) => { const result = await app.execute(OrderWorkflowStart, { orderId: "ORD-42" }); expect(result.finish).toBe(OrderWorkflowFinish); expect(result.output).toEqual({ trackingNumber: "trk-res-ORD-42" });});What error classes can .onError() and .onJoinError() catch?
Section titled “What error classes can .onError() and .onJoinError() catch?”| Error Class | Where It Attaches | When It Fires |
|---|---|---|
Custom Error subclass (e.g. CarrierError) |
.onError(CarrierError, ...) after @Action / @Router |
When the step throws CarrierError (or an ActionFailedError wrapping it in cause) |
ChildExecutionFailedError |
.onError(ChildExecutionFailedError, ...) after .toWorkflow(Child) |
When a child workflow execution fails without reaching one of its @WorkflowFinish nodes |
VisitsLimitExceededError |
.onError(VisitsLimitExceededError, ...) |
When an @Agent or @Router in a review loop exceeds its maxVisits ceiling |
BudgetExceededError / StepsLimitExceededError / DepthLimitExceededError / LimitExceededError |
.onError(LimitExceededError, ...) |
When an execution or step limit is exceeded. Note ([graph.limit-handler-uses-model]): side paths catching limit errors may only run deterministic @Action and DecidedBy.Code @Router steps — invoking an @Agent or DecidedBy.Model @Router inside a limit handler is rejected at assembly time. |
StepFailedError |
.onJoinError(StepFailedError, ...) after .join() |
When one or more parallel branches fail during .toParallelAgents() or .toParallelWorkflows() |
QuorumNotReachedError |
.onJoinError(QuorumNotReachedError, ...) after .joinQuorum() / .joinQuorumGroups() |
When too many branches fail or the quorum deadline expires before the quorum is satisfied |
How error matching and checkpoint recovery work
Section titled “How error matching and checkpoint recovery work”- Most-specific class matching: A step may declare multiple
.onError(...)handlers; the framework matches the thrown error (and its.causechain) against the most specific subclass in the prototype hierarchy. Duplicate handlers for the same error class on one step fail assembly ([flow.duplicate-error-handler]). - Durable across checkpoints: The failure record (
lastErrorwith error code, message, and failed node name) is persisted in workflow state so a side path that pauses for human intervention resumes cleanly.
How do declarative transactional sagas (compensateWith & committedBy) and automatic LIFO unwinding work?
Section titled “How do declarative transactional sagas (compensateWith & committedBy) and automatic LIFO unwinding work?”Manual .onError(...) chains only catch step failures along normal graph edges — they do not run when an execution or parallel branch is externally aborted by app.cancel(threadId), sibling FailurePolicy.FailFast, quorum BranchVerdict.Surplus, or a limit error (BudgetExceededError, StepsLimitExceededError, DepthLimitExceededError).
AGBoss solves both static rollback completeness and runtime external aborts with declarative compensation contracts on @Action:
- Declarative
@Actionsaga options:compensateWith: WithdrawOfferActiondeclares the deterministic@Actionthat reverses this action’s external side effect. Itsinputmust be satisfiable from the Path Scope immediately after the compensated action completes (verified at assembly; otherwise fails with[action.invalid-compensator-input]).committedBy: ConfirmOfferAction(optional) marks the commit boundary: onceConfirmOfferActioncompletes, the earlier action is finalized and will no longer be rolled back on subsequent errors or cancellations.
- Static saga verification in
agboss check([flow.uncompensated-action]):- Every error path (
.onError/.onJoinError) after an uncommitted compensatable@Actionthat reaches a@WorkflowFinishbefore passing throughcommittedBymust executecompensateWithon that path. If any error branch bypasses the compensator,agboss checkfails assembly with[flow.uncompensated-action]. agboss describeandagboss describe --jsonincludecompensateWithandcommittedBymetadata on action nodes.
- Every error path (
- Automatic runtime LIFO unwinding on external abort:
- Whenever an
@ActionwithcompensateWithcompletes, the runtime checkpoints an uncommitted compensation entry(stepId, action, outputDto, compensator). - If the execution or branch is aborted before
committedBy(viaapp.cancel(threadId),FailurePolicy.FailFast, quorumSurplus, orBudgetExceededError), the runtime automatically executes all uncommitted compensators in reverse completion order (LIFO) using a live cleanup signal bounded by@Application({ compensationTimeout: seconds(15) }). - If a worker pod crashes mid-rollback, the recovering instance resumes the remaining uncompensated entries idempotently using deterministic
step.idempotencyKeyvalues.
- Whenever an
import { Action, ExecutionContext, type ActionHandler, type InputOf, type OutputOf } from "@agboss/core";import { WithdrawOfferAction } from "./withdraw-offer.action.js";
@Action({ name: "register-offer-action", description: "Creates a provisional offer in the external ATS; rolled back by WithdrawOfferAction", input: { draft: OfferDraftDto }, output: RegisteredOfferDto, compensateWith: WithdrawOfferAction, committedBy: ConfirmOfferAction, deps: [ExecutionContext],})export class RegisterOfferAction implements ActionHandler<RegisterOfferAction> { constructor(private readonly executionContext: ExecutionContext) {}
async execute(input: InputOf<RegisterOfferAction>): Promise<OutputOf<RegisterOfferAction>> { return { offerId: `ats_${this.executionContext.step.idempotencyKey}`, role: input.draft.value.role, }; }}import { Action, ExecutionContext, type ActionHandler, type InputOf } from "@agboss/core";
@Action({ name: "withdraw-offer-action", description: "Revokes the provisional ATS offer under a live compensationTimeout cleanup signal", input: { offer: RegisteredOfferDto }, deps: [ExecutionContext],})export class WithdrawOfferAction implements ActionHandler<WithdrawOfferAction> { constructor(private readonly executionContext: ExecutionContext) {}
async execute(input: InputOf<WithdrawOfferAction>): Promise<void> { void input.offer.value.offerId; void this.executionContext.signal; }}import { expect, it } from "vitest";import { testAction } from "@agboss/core/testing";import { OfferSagaStart, RegisterOfferAction,} from "../src/actions/register-offer.action.js";import { WithdrawOfferAction } from "../src/actions/withdraw-offer.action.js";
it("executes RegisterOfferAction and compensates with WithdrawOfferAction", async () => { const runRegister = testAction(RegisterOfferAction); const { output } = await runRegister({ draft: { value: { role: "Principal Distributed Systems Engineer" }, producer: OfferSagaStart, }, }); expect(output.role).toBe("Principal Distributed Systems Engineer");
const runWithdraw = testAction(WithdrawOfferAction); await runWithdraw({ offer: { value: output, producer: RegisterOfferAction, }, });});Standalone Executable Proof: 04-travel-booking-saga
Section titled “Standalone Executable Proof: 04-travel-booking-saga”Inspect 04-travel-booking-saga and 02-path-scope-proof (cases/04-uncompensated-action) in agbossjs/agboss-examples for runnable proofs of reverse-order LIFO compensation across payment-saga, budget-saga, quorum-saga, and hitl-hold-saga, idempotent external ledgers (step.idempotencyKey), and crash-safe SQLite WAL recovery (see 7 Verified Examples).