Skip to content

Errors Deserve Better in awaitly

This article focuses on one pattern: sagas (createSagaWorkflow, from awaitly/durable).

The “Errors Deserve Better” scenario is not just about classifying failures. It is about safely handling a multi-step flow where side effects can partially succeed (charge captured) before a later step fails (reservation submit/persist).

Booking flow:

  1. Validate input
  2. Confirm availability
  3. Authorize payment
  4. Submit reservation
  5. Persist confirmation

In OTA-299 style timeout-after-capture, step 3 may have succeeded remotely even when the client sees a timeout. If later logic retries blindly, you can double-charge.

Typed errors are necessary, but for this flow they are not sufficient. You also need compensation.

Use case API Why
Wrap one async SDK call at an edge with typed mapping (optional retry) tryAsync + the retry policy Convert a throwing call to a typed Result, then wrap it in retry — both from awaitly
Wrap a throwing operation inside a workflow/saga step step.try('id', fn, { onError, retry?, timeout? }) Throwing op with typed error mapping, retry, timeout, and compensation in one call
Retry a step that already returns Result/AsyncResult step.retry Native step-level retry semantics and observability
Multi-step flow with rollback/compensation needs createSagaWorkflow Typed errors plus reverse-order compensation on downstream failure

This approach does not require a new execution model. It composes primitives awaitly already has:

  1. tryAsync (optionally wrapped in the retry policy) at vendor edges
  2. TaggedError for typed domain failures
  3. step semantics (early exit on Err) inside workflow/saga execution
  4. saga compensation for rollback of committed side effects

Inside a saga, step.try folds edge mapping, retry, and compensation into a single call over the same primitives.

A saga gives you both:

  1. Typed, explicit failure channels (PaymentLimbo, RateChanged, etc.)
  2. Built-in rollback semantics through compensations

That matches transactional booking behavior more closely than Result-only handling.

import { createSagaWorkflow } from 'awaitly/durable';
import { TaggedError } from 'awaitly';
class PaymentLimbo extends TaggedError('PaymentLimbo')<{
reservationAttemptId: string;
cause: unknown;
}> {}
class TransientVendorError extends TaggedError('TransientVendorError')<{
vendor: string;
cause: unknown;
}> {}
const reserveRoomSaga = createSagaWorkflow('reserve-room', {
validateBookingRequest,
confirmAvailability,
authorizePayment,
submitReservation,
persistConfirmation,
refundPayment,
});
const result = await reserveRoomSaga.run(async ({ step, deps }) => {
const validated = await step('validateBooking', () =>
deps.validateBookingRequest(req)
);
const locked = await step('confirmAvailability', () =>
deps.confirmAvailability(validated)
);
const payment = await step.try(
'authorizePayment',
() => deps.authorizePayment(validated),
{
onError: (cause) =>
isTimeoutAfterCapture(cause)
? new PaymentLimbo({
reservationAttemptId: validated.attemptId,
cause,
})
: new TransientVendorError({ vendor: 'stripe', cause }),
retry: {
attempts: 4,
initialDelay: 25,
shouldRetry: (e) => e instanceof TransientVendorError,
},
// Critical for this flow: if a later step fails, refund automatically.
compensate: (txn) => deps.refundPayment(txn.id),
}
);
const submitted = await step('submitReservation', () =>
deps.submitReservation(locked, payment)
);
return step('persistConfirmation', () =>
deps.persistConfirmation(submitted)
);
});

If submitReservation or persistConfirmation fails after payment succeeded, saga runs compensation in reverse order and calls refundPayment.

Outside a workflow, compose the same behavior from tryAsync (throw → typed Result) and the retry policy:

import { tryAsync, retry } from 'awaitly';
const authorize = () =>
tryAsync(
() => deps.authorizePayment(validated),
(cause) =>
isTimeoutAfterCapture(cause)
? new PaymentLimbo({ reservationAttemptId: validated.attemptId, cause })
: new TransientVendorError({ vendor: 'stripe', cause }),
);
const payment = await retry(authorize, {
attempts: 4,
delay: 25,
backoff: 'exponential',
retryIf: (e) => e instanceof TransientVendorError,
})();
const ux = TaggedError.match(error, {
RoomUnavailable: () => ({ kind: 'show-alternates' as const }),
RateChanged: () => ({ kind: 'reconfirm-price' as const }),
PaymentLimbo: () => ({ kind: 'escalate' as const, doNotRetry: true }),
TransientVendorError: () => ({ kind: 'silent-retry' as const }),
InvalidBookingInput: () => ({ kind: 'form-error' as const }),
});

Adding a new error variant forces a new handler at compile time.

better-result vs awaitly sagas for This Case

Section titled “better-result vs awaitly sagas for This Case”

better-result is excellent for typed Result and exhaustive error matching.

For this booking flow, an awaitly saga is clearer because it also models rollback for partial side effects. That is the decisive requirement in payment-plus-reservation orchestration.

Two cases:

  1. If you only need typed edge classification, better-result is a strong fit.
  2. If you need typed classification plus rollback semantics in a multi-step transaction, an awaitly saga is the better fit.

This test suite shows:

  1. Naive timeout retry can duplicate charge.
  2. Typed classification blocks retry on PaymentLimbo.
  3. Saga compensation refunds when downstream steps fail after payment.