Fecha: 2026-06-09 Resultado: BUG REPRODUCIDO ✓ (con 4 workers) — y caso de control con 1 worker: 0 fallos ✓
En producción (odoo.sh), los pagos con 3D Secure de payment_azul_webservices se quedan
esperando: el reto (challenge) nunca llega o el callback del banco falla. En test/staging
funciona siempre.
pyazul guarda las sesiones 3DS en un diccionario Python en memoria
(SecureService.session_store, en pyazul/services/secure.py). El módulo de Odoo cachea
el cliente pyazul a nivel de módulo (payment_azul_webservices/utils.py →
_client_cache = {}), es decir, un caché por proceso.
- odoo.sh producción corre N workers HTTP = N procesos separados (prefork), sin afinidad de sesión: cada request puede caer en cualquier worker.
- odoo.sh staging/test corre 1 solo worker (restricción documentada en el FAQ de odoo.sh) → todos los requests caen en el mismo proceso → nunca falla ahí.
Flujo que muere en producción:
1. POST /payment/azul_webservices/process → Worker A: pyazul guarda sesión 3DS en SU memoria
2. ACS → /payment/azul_webservices/3ds_return (method notification) → Worker B: sesión no existe → SESSION_NOT_FOUND
3. ACS → /payment/azul_webservices/3ds_return (CRes del reto) → Worker C: AzulError "No session data found for session_id"
Con 4 workers, la probabilidad de que los 3 pasos caigan en el mismo worker es ~6%. El propio README de pyazul lo advierte: "For production, your application MUST implement its own persistent session management (e.g., Redis, database)".
Contenedor azul_3ds_repro: Odoo 17 real con --workers=4 (mismo modo prefork que
odoo.sh producción) + addon mínimo azul_mw_repro que ejecuta el código real de pyazul:
| Endpoint | Simula | Qué hace |
|---|---|---|
GET /azul_repro/start |
El inicio del pago (secure_sale) |
Siembra la sesión 3DS en el session_store de pyazul del worker que atiende, igual que hace _process_secure_transaction. Devuelve el pid del worker y el secure_id |
GET /azul_repro/challenge?secure_id=... |
El callback del ACS con el CRes (/3ds_return) |
Llama al SecureService.process_challenge REAL de pyazul con ese secure_id |
No se necesita red ni credenciales de Azul: el error ocurre antes de cualquier
llamada HTTP (pyazul busca la sesión primero y lanza el error si no está). La única parte
simulada es la respuesta del banco cuando la sesión SÍ se encuentra (stub APROBADA).
Archivos en este directorio:
corrida_evidencia.txt— respuestas HTTP de la corrida completalogs_evidencia.txt— logs del servidor Odoo filtrados por esta transacción
Pago iniciado en worker PID 11:
START: {"pid": 11, "secure_id": "b804e672-5590-4fb1-9701-df7378818cbd", "sessions_in_this_worker": 1}Callbacks del reto (CRes) — 8 de 8 fallaron porque cayeron en workers 10 y 12:
CHALLENGE #1: {"pid": 10, "sessions_in_this_worker": 0, "reproduced": true,
"error": "3DS challenge processing failed: No session data found for session_id: b804e672-..."}
...
CHALLENGE #7: {"pid": 12, "sessions_in_this_worker": 2, "reproduced": true,
"error": "3DS challenge processing failed: No session data found for session_id: b804e672-..."}Caso control — cuando el callback por fin cayó en el worker 11 (el que inició), la sesión existía y el flujo completó:
CHALLENGE (control): {"pid": 11, "sessions_in_this_worker": 1, "reproduced": false,
"azul_response": {"ResponseMessage": "APROBADA", "IsoCode": "00", "AzulOrderId": "999999999"}}19:41:35 INFO [azul_repro] PID 11: sesión 3DS creada secure_id=b804e672-...
19:41:35 ERROR pyazul.services.secure: 3DS challenge processing failed: No session data found for session_id: b804e672-...
19:41:35 ERROR [azul_repro] PID 10: BUG REPRODUCIDO: ...No session data found...
19:41:35 ERROR [azul_repro] PID 12: BUG REPRODUCIDO: ...No session data found...
19:41:36 INFO [azul_repro] PID 11: challenge OK (mismo worker que inició)
Detalles que confirman el mecanismo:
- El error
No session data found for session_idlo lanza pyazul mismo (pyazul.services.secure) — es el mismo error que aparece en producción. sessions_in_this_workermuestra que cada worker tiene SU propio diccionario: el worker 10 tiene 0 sesiones, el 11 tiene 1 (la de este pago), el 12 tiene 2 (residuos de pagos iniciados ahí en corridas anteriores). Memoria fragmentada por proceso.- En cuanto el request cae en el worker correcto, todo funciona → exactamente por eso staging (1 worker) nunca falla.
Mismo entorno, mismo addon, mismo código de pyazul; el único cambio fue
--workers=4 → --workers=1 en docker-compose.yml (la configuración que odoo.sh usa
en staging/test). Archivos:
corrida_evidencia_1worker.txt— respuestas HTTP de la corrida completalogs_evidencia_1worker.txt— logs del servidor filtrados de esta corrida
Se iniciaron 3 pagos y se dispararon 8 callbacks de reto (CRes) por pago — 24 challenges en total, con conexión nueva por request (igual que la corrida de 4 workers). Resultado: 24 de 24 APROBADA, 0 fallos. Todos los requests cayeron en el único worker (PID 10), porque no hay otro:
START #1: {"pid": 10, "secure_id": "0d3ec036-4971-4410-8b75-6dce303d4130", "sessions_in_this_worker": 1}
CHALLENGE #1.1: {"pid": 10, "sessions_in_this_worker": 1, "reproduced": false,
"azul_response": {"ResponseMessage": "APROBADA", "IsoCode": "00", "AzulOrderId": "999999999"}}
...
START #3: {"pid": 10, "secure_id": "719851b1-e2cd-4962-97f3-ea45b4f92bb5", "sessions_in_this_worker": 3}
CHALLENGE #3.8: {"pid": 10, "sessions_in_this_worker": 3, "reproduced": false, "azul_response": {"ResponseMessage": "APROBADA", ...}}Nótese sessions_in_this_worker subiendo 1 → 2 → 3: con un solo proceso, todas las
sesiones viven en el mismo diccionario, así que el challenge siempre las encuentra.
19:56:45 INFO [azul_repro] PID 10: sesión 3DS creada secure_id=0d3ec036-...
19:56:45 INFO [azul_repro] PID 10: challenge OK (mismo worker que inició) ← ×8
19:56:45 INFO [azul_repro] PID 10: sesión 3DS creada secure_id=da15e365-...
19:56:45 INFO [azul_repro] PID 10: challenge OK (mismo worker que inició) ← ×8
19:56:45 INFO [azul_repro] PID 10: sesión 3DS creada secure_id=719851b1-...
19:56:45 INFO [azul_repro] PID 10: challenge OK (mismo worker que inició) ← ×8
Cero líneas ERROR, cero No session data found — el error desaparece por completo
sin tocar una sola línea de código.
| 4 workers (odoo.sh producción) | 1 worker (odoo.sh staging/test) | |
|---|---|---|
| Corrida | 1 pago, 8 challenges + 1 control | 3 pagos, 24 challenges |
| Challenges fallidos | 8 de 8 (cayeron en PID 10/12, sesión en PID 11) | 0 de 24 |
| Error | No session data found for session_id (el de producción) |
ninguno |
| Éxito solo cuando… | el callback cae por azar en el worker que inició (~25%) | siempre — solo existe un worker |
sessions_in_this_worker |
fragmentado: worker 10 → 0, worker 11 → 1, worker 12 → 2 | acumulado en un solo proceso: 1 → 2 → 3 |
| Evidencia | corrida_evidencia.txt, logs_evidencia.txt |
corrida_evidencia_1worker.txt, logs_evidencia_1worker.txt |
Lectura: misma imagen, mismo addon, mismo pyazul, misma base de datos — la única variable entre las dos corridas fue el número de workers. Con N>1 procesos el flujo 3DS falla salvo que el azar reparta los 3 pasos al mismo proceso; con 1 proceso es imposible que falle. Esto explica al 100% el comportamiento observado: producción (multi-worker) falla intermitentemente, staging/test (1 worker) nunca.
cd ~/repos/azul-3ds-repro
docker compose up -d # usa el postgres compartido odoo-db (debe estar corriendo)
⚠️ La imagendev_env_odoo_pro-17-odoono trae pyazul instalado. Si recreas el contenedor (docker compose down+up), reinstálalo:docker cp ~/repos/dev_env_odoo_pro-17/pyazul azul_3ds_repro:/tmp/pyazul-src docker exec -u root azul_3ds_repro bash -c "pip3 install --upgrade pip setuptools && pip3 install /tmp/pyazul-src" docker compose restart odooCon
docker compose restartnormal no se pierde (es el mismo contenedor).
./repro.shInicia un pago y dispara callbacks hasta que uno caiga en otro worker. Termina con
BUG REPRODUCIDO y el error de pyazul.
- Logs en una terminal:
docker logs -f azul_3ds_repro 2>&1 | grep azul_repro - Abre
http://localhost:8095/azul_repro/start→ anotapidy copiasecure_id - Abre
http://localhost:8095/azul_repro/challenge?secure_id=<SECURE_ID>y refresca (F5) varias veces - Lee cada respuesta:
piddistinto al del paso 2 →"reproduced": true+ errorNo session data found← producciónpidigual al del paso 2 →"reproduced": false+APROBADA← staging/test
Cambia --workers=4 por --workers=1 en docker-compose.yml, docker compose up -d
(reinstala pyazul, ver arriba), repite la opción B: nunca falla. Vuelve a
--workers=4: falla casi siempre. Esa es la diferencia completa producción vs test.
Esta opción ya fue ejecutada y documentada en la sección 5 (corrida de control).
docker compose down # no toca tu dev env ni el postgres compartidoLa hipótesis de workers queda confirmada empíricamente en ambas direcciones: con 4
workers el flujo 3DS falla (8/8 challenges con No session data found), y con la única
variable cambiada a 1 worker el mismo flujo completa siempre (24/24 APROBADA). El estado
3DS en memoria de proceso no sobrevive el balanceo entre workers de odoo.sh producción
(ni el reciclaje de workers).
Fix recomendado en payment_azul_webservices: no depender del session_store de
pyazul. Los endpoints de Azul ProcessThreeDSMethod y ProcessThreeDSChallenge solo
necesitan AzulOrderId (+ Channel, Store, y Amount/OrderNumber para el method),
y AzulOrderId ya se persiste en la base de datos (payment.transaction.azul_order_id).
El controller debe construir esos requests desde la transacción en DB y llamar los
endpoints directo (o re-sembrar el session_store del worker receptor desde la DB antes
de llamar a pyazul). Adicional: ampliar el cron de verificación para rescatar
transacciones 3DS atascadas, que hoy están excluidas del dominio de búsqueda.