The problem: the ETA call hangs. Not fails, hangs, holding a thread indefinitely while the customer stares at a spinner and the next order piles up behind it.
A hang is worse than an error, because an error you can handle and a hang just consumes you. Every outbound call needs a timeout (Nygard, Release It!). Pair it with a fallback: when the ETA call times out or fails, return something useful instead of an exception, a default "30-45 min" estimate, a cached quote, a degraded-but-honest answer.
var resilientEta = new ResiliencePipelineBuilder<DeliveryEta>()
.AddTimeout(TimeSpan.FromSeconds(2))
.AddFallback(new FallbackStrategyOptions<DeliveryEta>
{
FallbackAction = _ => Outcome.FromResultAsValueTask(DeliveryEta.Default),
ShouldHandle = new PredicateBuilder<DeliveryEta>()
.Handle<TimeoutRejectedException>()
})
.Build();What it buys you in production: bounded latency and a graceful answer. The customer gets a default ETA in two seconds instead of a blank screen in thirty, and the order still goes through. A bounded wait is the difference between a slow checkout and a dead one.
Skip-if: you genuinely have no acceptable fallback and a wrong answer would be worse than no answer. The charge itself is like that, you can't fall back to a "default" payment. Keep the timeout regardless; it's the fallback that's optional, not the bound on waiting.
The problem: you retried the charge, but you don't know whether the first attempt actually landed before the connection dropped, so retrying might bill the customer twice and place two Orders for one basket.
This is the pattern that licenses the first one. An operation is idempotent if doing it twice has the same effect as doing it once. Reads are idempotent for free. Writes are not, until you make them so, usually with an idempotency key: the customer app sends a unique token with the basket, you record it on first execution, and a retry carrying the same token returns the original Order instead of placing a second one (Stripe's API documents this pattern well, which is no accident; it's a payments problem first). The same key flows through to the payment gateway, so the charge is deduplicated end to end.
// On the order-placement path, keyed by the basket's token.
var existing = await _orderGateway.FindByIdempotencyKeyAsync(key);
if (existing is not null) return existing; // replay, don't re-place
var order = await _orderGateway.PlaceAndRecordAsync(key, request);
return order;What it buys you in production: safe retries. Without idempotency, retry is a loaded gun pointed at the customer's card; with it, the worst a duplicate request can do is return the same Order twice. Every at-least-once message broker (Ch 6) leans on this too, which matters once OrderPlaced fans out to the kitchen and courier-matching consumers.
Skip-if: the operation is already a pure read, or naturally idempotent (setting an order's status to a fixed value). Don't bolt a key store onto something that can't double-fire.
Retry is the promise. Idempotency is the thing that lets you keep it.
Download the full PDF for free?
Free download — no account required