Skip to content

SAGA Pattern

The SAGA pattern provides compensating transactions for workflows where true rollback is impossible. When you call an external API, send an email, or charge a credit card, you can't "un-send" the email or "un-call" the API — but you can execute a compensating action (refund the charge, send a cancellation, etc.).

When to Use SAGAs

Use atomic groups with on_failure: rollback when:

  • All operations are local (database writes, in-memory state).
  • You can restore to a previous snapshot.

Use SAGAs when:

  • Operations involve external APIs (payment gateways, shipping providers).
  • Operations are irreversible but have known compensations (charge → refund).
  • You need best-effort cleanup rather than guaranteed rollback.

How It Works

The SAGA Coordinator executes steps in order. If a step fails:

  1. Stop executing forward.
  2. Walk backward through completed steps.
  3. Execute each step's compensate_handler (if defined).
  4. Continue compensating even if individual compensations fail.
  5. Raise SagaCompensationError with details of any compensation failures.
Forward execution:     Step 1 → Step 2 → Step 3 (FAILS)
Compensation (reverse): Step 2.compensate → Step 1.compensate

SagaCoordinator

from honey_badgeria.back.atomicity.saga import SagaCoordinator, SagaStep

saga = SagaCoordinator(
    name="process_payment",
    backend=transaction_backend,
    logger=ai_logger,
)

Adding Steps

saga.add_step(SagaStep(
    name="validate_card",
    vertex_name="validate_card",
    compensate_handler=None,  # Nothing to undo for validation
))

saga.add_step(SagaStep(
    name="charge_card",
    vertex_name="charge_card",
    compensate_handler=lambda inputs, result: refund_api(result["txn_id"]),
))

saga.add_step(SagaStep(
    name="send_receipt",
    vertex_name="send_receipt",
    compensate_handler=lambda inputs, result: send_cancellation(result["email"]),
))

SagaStep Fields

Field Type Description
name str Step identifier
vertex_name str Vertex to execute
compensate_handler Callable \| None Function to undo this step (receives inputs and result)

Executing

Synchronous:

results = saga.execute(step_executor_fn)
# Returns list[dict] — one result per step

Asynchronous:

results = await saga.execute_async(step_executor_fn)

The step_executor_fn is a callable that receives a step and returns the step's result:

def step_executor(step):
    handler = handlers[step.vertex_name]
    return handler(**resolved_inputs)

Compensation Behavior

Success Path

All steps execute forward. No compensation needed.

validate_card ✓ → charge_card ✓ → send_receipt ✓
Done.

Failure Path

If send_receipt fails:

validate_card ✓ → charge_card ✓ → send_receipt ✗
                  Compensate:      │
                  charge_card.compensate ← (refund)
                  validate_card.compensate ← (None, skipped)
  1. send_receipt fails — stop forward execution.
  2. Walk backward: charge_card has a compensate handler → execute it (refund).
  3. Walk backward: validate_card has no compensate handler → skip.
  4. Raise SagaStepError with the original exception.

Compensation Failures

Compensation is best-effort. If a compensate handler also fails:

charge_card.compensate ✗ (refund API is down)

HBIA logs the failure but continues compensating remaining steps. After all compensations are attempted, it raises SagaCompensationError containing a list of all compensation failures.

Example: Order Processing

from honey_badgeria.back.atomicity.saga import SagaCoordinator, SagaStep

saga = SagaCoordinator("process_order", backend, logger)

# Step 1: Reserve inventory
saga.add_step(SagaStep(
    name="reserve_inventory",
    vertex_name="reserve_inventory",
    compensate_handler=lambda inputs, result: release_inventory(result["items"]),
))

# Step 2: Charge customer
saga.add_step(SagaStep(
    name="charge_customer",
    vertex_name="charge_customer",
    compensate_handler=lambda inputs, result: refund_payment(result["payment_id"]),
))

# Step 3: Schedule shipping
saga.add_step(SagaStep(
    name="schedule_shipping",
    vertex_name="schedule_shipping",
    compensate_handler=lambda inputs, result: cancel_shipment(result["tracking_id"]),
))

# Step 4: Send confirmation email
saga.add_step(SagaStep(
    name="send_confirmation",
    vertex_name="send_confirmation",
    compensate_handler=lambda inputs, result: send_cancellation_email(result["email"]),
))

# Execute
try:
    results = saga.execute(step_executor)
    print("Order processed successfully")
except SagaStepError as e:
    print(f"Order failed at step '{e.step_name}': {e.original}")
    # Compensations have already been attempted
except SagaCompensationError as e:
    print(f"Order failed AND some compensations failed: {e.failures}")
    # Manual intervention may be needed

SAGA Events

The AILogger records all SAGA events for observability:

Event When
SAGA_STARTED SAGA execution begins
SAGA_STEP_STARTED Each step begins
SAGA_STEP_COMPLETED Each step succeeds
SAGA_STEP_FAILED A step fails
SAGA_COMPLETED SAGA execution finishes (success or failure)
from honey_badgeria.logging import AILogger, EventType

logger = AILogger("hbia")
# Events are automatically recorded during SAGA execution
events = logger.get_events()

Atomicity vs. SAGA: Decision Guide

Question Use Atomic Group Use SAGA
All operations are local?
Operations involve external APIs?
True rollback is possible?
Need compensating reactions?
Need guaranteed consistency?
Need best-effort cleanup?