El problema
Confirmar un pedido son tres cosas: reservar el stock, cobrar el pago y avisar al almacén. Cada una vive en un servicio distinto, con su propia base de datos.
En un monolito con una base relacional esto era una transacción: BEGIN, tres
operaciones, COMMIT. Si algo fallaba, ROLLBACK y como si nada hubiera
pasado.
Aquí no existe ese ROLLBACK. No hay transacción que abarque tres servicios,
así que si el pago falla después de reservar el stock, tienes veinte unidades
apartadas para un pedido que no existe. Y si el aviso al almacén falla después
de cobrar, has cobrado un envío que nadie va a preparar.
Escribir esto a mano en una Lambda es donde empieza el verdadero problema. El código honesto tiene esta forma:
try {
await reservarStock(pedido);
try {
await cobrarPago(pedido);
try {
await confirmarEnvio(pedido);
} catch { await devolverCobro(pedido); await liberarStock(pedido); throw; }
} catch { await liberarStock(pedido); throw; }
} catch { /* ¿y si liberarStock también falla? */ }
Tres problemas, y el tercero es el grave:
- Anidamiento que crece al cuadrado. Cada paso nuevo duplica los caminos de vuelta.
- Las compensaciones también fallan. El
catchque deshace puede reventar, y no hay uncatchdelcatchque sea sensato. - Si el proceso muere, no queda nada. El progreso vivía en la pila de una función que ya no existe. Nadie sabe que hay stock reservado esperando a que alguien lo libere.
La solución
Sacar el proceso del código y meterlo en una máquina de estados que persiste entre pasos.
Cada paso es un estado con su Retry —cuántas veces, con qué backoff— y su
Catch, que apunta al estado de compensación correspondiente. Las
compensaciones se encadenan hacia atrás: si falla el envío, se devuelve el cobro
y luego se libera el stock.
Lo que cambia respecto al try/catch anidado no es la lógica, que es la misma.
Es dónde vive el progreso. Step Functions guarda el estado fuera de tu
código, así que:
- Si una compensación falla, se reintenta sola, sin perder el contexto.
- Si todo revienta, la ejecución queda visible y parada en el estado concreto donde se rompió, con su entrada — no desaparecida.
- Añadir un cuarto paso es añadir un estado y su compensación, no un nivel más de anidamiento.
Por qué Standard y no Express
Para una saga con pasos no idempotentes la elección está forzada, y AWS lo dice casi con estas palabras: Standard sigue un modelo de exactamente-una-vez, lo que lo hace adecuado para orquestar acciones no idempotentes como procesar pagos. Express es al-menos-una-vez.
A eso se suma que Express no soporta .waitForTaskToken, así que cualquier
saga con un paso que espera —una aprobación humana, la respuesta de un tercero—
solo puede ser Standard.
Cuándo usarlo
- Una operación de negocio toca varios servicios y dejarla a medias tiene consecuencias visibles: dinero, stock, permisos.
- Existe una compensación de negocio real para cada paso. Si no se puede deshacer, no hay saga: hay que reordenar los pasos para que lo irreversible vaya al final.
- El proceso tiene esperas largas o pasos de aprobación humana.
- Quieres ver dónde se rompió sin reconstruirlo a partir de logs.
Cuándo NO usarlo
Cómo implementarlo
- Ordena los pasos poniendo lo irreversible al final. Es la decisión que más reduce el número de compensaciones que hay que escribir.
- Escribe la compensación de cada paso antes que el paso. Si no sabes deshacerlo, todavía no sabes si puedes hacerlo.
Retrypor tipo de error: reintenta timeouts y throttling, nunca errores de validación — reintentar un400es quemar dinero.Catchhacia el estado de compensación correspondiente, encadenando hacia atrás.- Haz cada paso idempotente. Los
Retryhacen que se repita más, no menos. - Pasa referencias, no documentos: 256 KiB entre estados.
- Un estado final de fallo explícito que registre el caso irrecuperable en algún sitio que un humano mire.
El código
{
"Comment": "Saga de confirmacion de pedido",
"StartAt": "ReservarStock",
"States": {
"ReservarStock": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "reservarStock", "Payload.$": "$" },
"Retry": [
{
"ErrorEquals": ["Lambda.TooManyRequestsException", "States.Timeout"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2
}
],
"Next": "CobrarPago"
},
"CobrarPago": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "cobrarPago", "Payload.$": "$" },
"Retry": [
{ "ErrorEquals": ["States.Timeout"], "MaxAttempts": 2, "BackoffRate": 2 }
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "Next": "LiberarStock", "ResultPath": "$.error" }
],
"Next": "ConfirmarEnvio"
},
"ConfirmarEnvio": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "confirmarEnvio", "Payload.$": "$" },
"Catch": [
{ "ErrorEquals": ["States.ALL"], "Next": "DevolverCobro", "ResultPath": "$.error" }
],
"End": true
},
"DevolverCobro": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "devolverCobro", "Payload.$": "$" },
"Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 5, "BackoffRate": 2 }],
"Next": "LiberarStock"
},
"LiberarStock": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "liberarStock", "Payload.$": "$" },
"Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 5, "BackoffRate": 2 }],
"Next": "PedidoFallido"
},
"PedidoFallido": { "Type": "Fail", "Error": "SagaCompensada" }
}
}
Fíjate en que las compensaciones llevan Retry más agresivo que los pasos
normales: una compensación que falla deja el sistema inconsistente, así que
merece más insistencia que la operación original.
Te va a morder
Coste
| Concepto | Efecto |
|---|---|
| Transiciones de estado (Standard) | ~25 USD / millón |
| Invocaciones de Lambda | una por paso, y otra por compensación |
| Esperar | gratis — un Wait cuesta una transición, no el tiempo |
La cuenta que hay que hacer antes de elegir Standard: cada paso completado es una transición facturada. Una saga de seis estados que se ejecuta un millón de veces al mes son seis millones de transiciones. Si el proceso es de alto volumen y corta duración, mira si los pasos pueden hacerse idempotentes y usar Express.
Lo que no cuesta es esperar. Un estado Wait de tres días para el plazo de
devolución vale una transición, no tres días de cómputo. Comparado con mantener
algo vivo, es gratis, y es la razón por la que los procesos con esperas largas
acaban aquí.
Fuentes
Semántica exactamente-una-vez de Standard frente a al-menos-una-vez de Express,
inmutabilidad del tipo de flujo y patrones de integración no soportados en
Express:
Choosing workflow type in Step Functions.
Límite de 256 KiB de entrada y salida, límite de 25 000 eventos del historial y
su comportamiento al alcanzarse, duraciones máximas y transiciones por región:
Step Functions service quotas.
Límites de TransactWriteItems como alternativa cuando todo cabe en DynamoDB:
Amazon DynamoDB Transactions.