Skip to content

Repository files navigation

Réplica: bug 3DS de payment_azul_webservices en multi-worker (odoo.sh producción)

Fecha: 2026-06-09 Resultado: BUG REPRODUCIDO ✓ (con 4 workers) — y caso de control con 1 worker: 0 fallos ✓

1. El problema

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.

2. Causa raíz

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)".

3. Qué hace este entorno

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).

4. Evidencia — corrida con 4 workers (2026-06-09 15:41, modo producción)

Archivos en este directorio:

  • corrida_evidencia.txt — respuestas HTTP de la corrida completa
  • logs_evidencia.txt — logs del servidor Odoo filtrados por esta transacción

Resumen de la corrida

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"}}

Logs del servidor (extracto de logs_evidencia.txt)

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_id lo lanza pyazul mismo (pyazul.services.secure) — es el mismo error que aparece en producción.
  • sessions_in_this_worker muestra 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.

5. Evidencia — corrida de control con 1 worker (2026-06-09 15:56, modo staging/test)

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 completa
  • logs_evidencia_1worker.txt — logs del servidor filtrados de esta corrida

Resumen de la 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.

Logs del servidor (extracto de logs_evidencia_1worker.txt)

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.

6. Comparación: 4 workers (producción) vs 1 worker (staging/test)

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.

7. Cómo validarlo tú mismo

Arrancar

cd ~/repos/azul-3ds-repro
docker compose up -d        # usa el postgres compartido odoo-db (debe estar corriendo)

⚠️ La imagen dev_env_odoo_pro-17-odoo no 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 odoo

Con docker compose restart normal no se pierde (es el mismo contenedor).

Opción A: script automático

./repro.sh

Inicia un pago y dispara callbacks hasta que uno caiga en otro worker. Termina con BUG REPRODUCIDO y el error de pyazul.

Opción B: manual con browser

  1. Logs en una terminal: docker logs -f azul_3ds_repro 2>&1 | grep azul_repro
  2. Abre http://localhost:8095/azul_repro/start → anota pid y copia secure_id
  3. Abre http://localhost:8095/azul_repro/challenge?secure_id=<SECURE_ID> y refresca (F5) varias veces
  4. Lee cada respuesta:
    • pid distinto al del paso 2 → "reproduced": true + error No session data found ← producción
    • pid igual al del paso 2 → "reproduced": false + APROBADA ← staging/test

Opción C: probar el "modo staging"

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).

Apagar

docker compose down    # no toca tu dev env ni el postgres compartido

8. Conclusión y fix recomendado

La 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages