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).
The Real Failure Mode
Section titled “The Real Failure Mode”Booking flow:
- Validate input
- Confirm availability
- Authorize payment
- Submit reservation
- 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.
API Chooser (Async/Await)
Section titled “API Chooser (Async/Await)”| 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 |
Built on Existing awaitly Primitives
Section titled “Built on Existing awaitly Primitives”This approach does not require a new execution model. It composes primitives awaitly already has:
tryAsync(optionally wrapped in theretrypolicy) at vendor edgesTaggedErrorfor typed domain failuresstepsemantics (early exit onErr) inside workflow/saga executionsagacompensation 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.
Why Sagas Are the Best Fit
Section titled “Why Sagas Are the Best Fit”A saga gives you both:
- Typed, explicit failure channels (
PaymentLimbo,RateChanged, etc.) - 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.
Minimal Variant With Core Primitives
Section titled “Minimal Variant With Core Primitives”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,})();UX Mapping Still Stays Exhaustive
Section titled “UX Mapping Still Stays Exhaustive”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:
- If you only need typed edge classification,
better-resultis a strong fit. - If you need typed classification plus rollback semantics in a multi-step transaction, an awaitly saga is the better fit.
Runnable Proof
Section titled “Runnable Proof”This test suite shows:
- Naive timeout retry can duplicate charge.
- Typed classification blocks retry on
PaymentLimbo. - Saga compensation refunds when downstream steps fail after payment.