Módulo 5: Thresholds — pasa/falla y SLOs
4. El exit code ≠ 0 que hace fallar el CI
Descripción
Un veredicto que nadie obedece no sirve de nada. En las lecciones 2 y 3 construimos el juicio —el threshold que dice pasa o falla—, pero un True/False que se queda dentro de un programa no detiene ningún deploy. Esta lección conecta ese veredicto con el mundo real a través de la pieza más humilde y más importante de toda la automatización: el código de salida (exit code). Cuando un programa termina, le deja al sistema operativo un número entero: 0 significa "todo bien"; cualquier otro número significa "fallé". Ese número es el idioma universal con el que un programa le habla a un pipeline de CI. Vas a ver cómo sys.exit produce ese número en Python, cómo el shell lo lee en la variable $?, cómo tu gate lo usa para salir con 0 (pasa) o 1 (falla) —ejecutado de verdad—, y cómo k6 hace exactamente lo mismo saliendo con el código 99 cuando un threshold falla. Al terminar entenderás por qué un threshold roto detiene un deploy sin que nadie apruebe nada: porque un número distinto de cero es todo lo que un pipeline necesita para poner el build en rojo.
Conexión con el módulo: las lecciones 2 y 3 produjeron el veredicto (en Python ejecutado y en k6 contenido); esta le da dientes. Es la fila que quedó pendiente en la tabla de mapeo de la lección 3: la consecuencia. Aquí el pasa/falla deja de ser un adorno en pantalla y se vuelve un evento que un sistema respeta. La generalización de esto —que este mecanismo es el mismo de los coverage gates— es la lección 6; el pipeline de CI completo, con su YAML, es el módulo 7. Aquí construimos el mecanismo del gate; M7 lo instala en el pipeline entero.
El sello del inspector de aduana
Cuando pasas una aduana, el oficial revisa tus papeles y hace una de dos cosas: te pone un sello de "aprobado" y te deja seguir, o te retiene y no avanzas. Lo que importa no es lo que el oficial piense de tus papeles —eso se queda en su cabeza—; lo que importa es el sello, porque el sello es lo que la siguiente puerta lee. La puerta no vuelve a revisar tus papeles: mira si tienes el sello. Sin sello, no pasas, y da igual cuán buenas fueran tus razones.
El código de salida es ese sello. Tu gate revisa las métricas y forma un veredicto —eso es como el oficial pensando—, pero lo que el pipeline lee no es el veredicto interno: es el número que el programa deja al terminar. 0 es el sello de aprobado; cualquier otro número es "retenido". La siguiente etapa del pipeline (el deploy) no vuelve a mirar tus latencias: mira el código de salida del paso anterior. Si es 0, sigue; si no, se detiene. Por eso toda la automatización de calidad se apoya en este número tan pequeño: es el sello que las puertas del pipeline saben leer.
sys.exit y $?: el número que deja un programa
En Python, un programa termina con un código de salida, y sys.exit(n) lo fija explícitamente. La convención es universal en todo Unix:
sys.exit(0)— éxito. "Terminé bien." Es también lo que pasa si el programa acaba sin llamar asys.exit(el código por defecto es 0).sys.exit(1)(o cualquier número distinto de 0) — fallo. "Algo salió mal."
El shell lee ese número en la variable especial $? (el código de salida del último comando). Veámoslo con el ejemplo más pequeño posible: un programa que compara un p95 medido contra un umbral y sale con el código correspondiente.
"""Demostracion minima de sys.exit y el codigo de salida."""
import sys
p95 = 246.96 # medido (carga pesada)
limit = 200.0 # el umbral (SLO)
if p95 < limit:
print(f"p(95)={p95:.2f}ms < {limit:.0f}ms -> PASS")
sys.exit(0) # 0 = exito: el pipeline continua
else:
print(f"p(95)={p95:.2f}ms >= {limit:.0f}ms -> FAIL")
sys.exit(1) # !=0 = fallo: el pipeline se detiene
Qué esperar — como 246.96 no es menor que 200, entra en el else, imprime FAIL y sale con 1; el shell, al preguntar $?, ve ese 1. Esto es salida real:
$ python3.14 exit_demo.py
p(95)=246.96ms >= 200ms -> FAIL
$ echo "el shell vio: \$? = $?"
el shell vio: $? = 1
Ahí está el eslabón completo, ejecutado: el programa juzgó (246.96 ≥ 200 → FAIL), lo tradujo a un número (sys.exit(1)), y el shell lo recibió ($? = 1). Ese 1 es el sello de "retenido". Si el p95 hubiera sido 150, habría entrado en el if, impreso PASS y salido con 0 —el sello de "aprobado", $? = 0—. Toda la lección se reduce a esa traducción: veredicto → número → algo que el pipeline lee.
El gate completo, saliendo con su código real
Ahora el gate de verdad, no el juguete. threshold_gate.py es la evaluate_thresholds de la lección 2 envuelta en un main que mide (lanza carga contra Reservo, junta latencias, cuenta errores y checks) y luego sale con el código correspondiente al veredicto:
def main():
latencies, error_rate, checks_rate = measure() # mide contra Reservo (real)
print(f"# {PATH} | {TOTAL_REQUESTS} peticiones, concurrencia {CONCURRENCY}")
ok = evaluate_thresholds(latencies, error_rate, checks_rate, P95_LIMIT_MS)
if ok:
print("GATE: PASS (exit code 0)")
sys.exit(0) # el veredicto True -> codigo 0 -> el pipeline sigue
else:
print("GATE: FAIL (exit code 1)")
sys.exit(1) # el veredicto False -> codigo 1 -> el pipeline se detiene
La última pieza del contrato: el booleano ok que devuelve evaluate_thresholds se convierte en el código de salida. True → sys.exit(0), False → sys.exit(1). Corramos las dos corridas de siempre y miremos, ahora sí, el $? que deja cada una. Primero la que pasa (carga liviana sobre /quote, el endpoint canónico rápido):
Qué esperar — las tres reglas pasan, el gate imprime PASS, y el shell recibe $? = 0:
$ python3.14 threshold_gate.py http://127.0.0.1:PORT /quote 400 20 200
# /quote | 400 peticiones, concurrencia 20
THRESHOLD MEDIDO RESULTADO
----------------------------------------------------------------------
http_req_duration: p(95) < 200ms p(95) = 8.33ms PASS
http_req_failed: rate < 1.00% rate = 0.00% PASS
checks: rate > 99.00% rate = 100.00% PASS
----------------------------------------------------------------------
GATE: PASS (exit code 0)
$ echo $?
0
Y ahora la que falla (carga pesada sobre /quote_cpu):
Qué esperar — el p95 cruza el umbral, el gate imprime FAIL, y el shell recibe $? = 1:
$ python3.14 threshold_gate.py http://127.0.0.1:PORT /quote_cpu 2000 120 200
# /quote_cpu | 2000 peticiones, concurrencia 120
THRESHOLD MEDIDO RESULTADO
----------------------------------------------------------------------
http_req_duration: p(95) < 200ms p(95) = 246.96ms FAIL
http_req_failed: rate < 1.00% rate = 0.00% PASS
checks: rate > 99.00% rate = 100.00% PASS
----------------------------------------------------------------------
GATE: FAIL (exit code 1)
$ echo $?
1
Dos veredictos, dos códigos de salida reales: 0 cuando la app cumple el SLO, 1 cuando no. Ese número es lo único que el pipeline va a mirar. Fíjate en que el $? = 1 de la segunda corrida salió aunque dos de los tres thresholds pasaron —una regla rota basta para el False, y el False basta para el 1—.
Cómo un número detiene un deploy
Un pipeline de CI (GitHub Actions, GitLab CI, Jenkins...) ejecuta una serie de pasos, y tiene una regla de oro: si un paso sale con un código distinto de cero, el paso falla, y (por defecto) el pipeline se detiene ahí. No corre los pasos siguientes. Como el deploy suele ser un paso posterior al de las pruebas, un paso de pruebas que sale con 1 impide que el deploy ocurra. Eso es todo el mecanismo: no hay magia, es la regla del código de salida aplicada a una cadena de pasos.
Podemos verlo sin un pipeline entero, con el propio shell, que tiene los mismos operadores que usan por dentro los sistemas de CI. A && B corre B solo si A tuvo éxito (código 0); A || B corre B solo si A falló (código ≠ 0). Simulemos "corre el gate, y solo si pasa, autoriza el deploy":
Qué esperar — cuando el gate pasa, el && deja correr el paso siguiente (deploy autorizado); cuando falla, el && se corta y el || dispara el mensaje de bloqueo. Esto es salida real:
$ # El gate PASA: el paso siguiente corre
$ python3.14 threshold_gate.py http://127.0.0.1:PORT /quote 300 10 200 > /dev/null \
&& echo "CI: deploy autorizado" || echo "CI: deploy BLOQUEADO"
CI: deploy autorizado
$ # El gate FALLA: el && corta, el CI se detiene
$ python3.14 threshold_gate.py http://127.0.0.1:PORT /quote_cpu 2000 120 200 > /dev/null \
&& echo "CI: deploy autorizado" || echo "CI: deploy BLOQUEADO"
CI: deploy BLOQUEADO
Ahí lo tienes, de punta a punta: la carga liviana pasó el gate → código 0 → el && autorizó el deploy; la carga pesada falló el gate → código 1 → el && se cortó y el deploy quedó bloqueado. Ningún humano miró un p95. El sello hizo todo el trabajo. Un pipeline de CI real hace exactamente esto, solo que el "paso siguiente" es el deploy de verdad y el "bloqueo" es que el build se pone rojo y nadie puede mergear. (El YAML concreto que expresa esta cadena en GitHub Actions es el módulo 7.)
k6 hace lo mismo: el código 99
k6 sigue esta misma convención, con un detalle que conviene conocer: cuando uno o más thresholds fallan, k6 run sale con el código 99 (internamente lo llama ThresholdsHaveFailed). No es un 1 cualquiera: k6 reserva el 99 específicamente para "las mediciones estuvieron bien, la prueba corrió completa, pero no cumplió los umbrales" —lo distingue de un 0 (todo pasó), de un error de script, o de un fallo de setup—. Para el pipeline la distinción fina no importa: 99 es distinto de cero, así que el paso falla y el deploy se detiene, exactamente como tu sys.exit(1).
# CONTENIDO (no ejecutado aqui): asi se comportaria k6. Ver grafana.com/docs/k6
$ k6 run quote_test.js
...
✗ http_req_duration..............: p(95)=246.96ms (umbral: p(95)<200)
✓ http_req_failed................: 0.00%
✓ checks.........................: 100.00%
$ echo $?
99
La ✗ roja de la lección 3 y este 99 son las dos caras del mismo evento: el threshold falló y por eso el proceso salió con un código distinto de cero. Ese número es el que hace que un k6 run en tu pipeline de CI ponga el build en rojo. Tu gate de Python usa 1 y k6 usa 99; ambos son "distinto de cero", y eso es lo único que el pipeline necesita. Que un threshold de rendimiento roto reprueba un build es, mecánicamente, idéntico a que un test unitario roto lo reprueba: los dos salen con código ≠ 0.
Errores comunes
Imprimir "FAIL" pero salir con código 0. Qué pasa: un script detecta el fallo, imprime un mensaje de error rojo y bonito... y termina normalmente, con código 0. El pipeline ve el 0, cree que todo salió bien, y despliega igual. Por qué pasa: se confunde comunicarle al humano que algo falló (el print) con comunicarle a la máquina que algo falló (el sys.exit). Cómo detectarlo: corre el script y haz echo $?; si dice 0 cuando debería fallar, el gate es decorativo. Cómo corregirlo: asegúrate de que la rama de fallo llame a sys.exit(1) (o cualquier ≠ 0). El print es para el humano; el exit code es para el pipeline. (Este bug es real y silencioso: el gate "se ve" que funciona pero nunca bloquea nada.)
Dejar que una excepción no controlada enmascare el veredicto. Qué pasa: el gate revienta con una excepción (p. ej. no pudo conectar al servidor) y sale con código 1 —el mismo que usa para "threshold roto"—, y no queda claro si falló el rendimiento o falló la medición. Por qué pasa: se usa el mismo código para dos cosas distintas. Cómo detectarlo: un FAIL sin la tabla de thresholds impresa suele ser un error de medición, no un threshold roto. Cómo corregirlo: reserva sys.exit(1) para "medí bien y un threshold falló"; deja que los errores de infraestructura salgan con otro código (o al menos con un mensaje distinto), como k6 distingue el 99 (thresholds) de sus otros códigos.
Suponer que el pipeline "sabe" lo que significa tu salida. Qué pasa: alguien espera que CI entienda un mensaje como "rendimiento degradado" en la salida de texto. Por qué pasa: se antropomorfiza al pipeline. Cómo detectarlo: si tu gate no fija un código de salida y confía en que CI "lea" el texto, no va a funcionar. Cómo corregirlo: el pipeline no lee tu texto; lee tu código de salida. Comunícate con él por el único canal que entiende: 0 o ≠ 0.
Ejercicios
Ejercicio 1 — Predice el $?. Para cada corrida, di qué imprime echo $? justo después. (a) El gate con las tres reglas en verde. (b) El gate con el p95 en rojo y las otras dos en verde. (c) El exit_demo.py con p95 = 150, limit = 200. (d) Un k6 run cuyo http_req_duration no cumple el threshold.
Ver solución
- (a)
0. Veredicto PASS →sys.exit(0). - (b)
1. Una regla rota → veredicto FAIL →sys.exit(1)(aunque dos reglas pasaron). - (c)
0. 150 < 200 → entra en elif→ PASS →sys.exit(0). - (d)
99. k6 sale con 99 (ThresholdsHaveFailed) cuando un threshold falla. Distinto de cero → el pipeline falla.
Ejercicio 2 — Arregla el gate decorativo. Este gate imprime el fallo pero el pipeline nunca lo bloquea. ¿Por qué, y cómo se arregla?
if not all_pass:
print("GATE: FAIL")
print("listo")
Ver solución
El problema: cuando all_pass es False, imprime "GATE: FAIL" pero no llama a sys.exit, así que el programa sigue, imprime "listo" y termina normalmente con código 0. El pipeline ve el 0 y despliega igual —el gate es decorativo—. Se arregla saliendo con código ≠ 0 en la rama de fallo:
if not all_pass:
print("GATE: FAIL")
sys.exit(1) # <-- esto es lo que bloquea el pipeline
print("GATE: PASS")
La lección: el print es para el humano, el sys.exit es para la máquina. Sin el sys.exit(1), no hay gate.
Ejercicio 3 — Encadena el gate y el deploy. Escribe una línea de shell que corra python3.14 threshold_gate.py ... y, solo si pasa, corra un ./deploy.sh (ficticio); y si el gate falla, imprima "deploy bloqueado" sin desplegar. ¿Qué operador usa la relación "solo si pasa"?
Ver solución
python3.14 threshold_gate.py http://127.0.0.1:PORT /quote 300 10 200 \
&& ./deploy.sh \
|| echo "deploy bloqueado"
El operador && expresa "solo si pasa": corre ./deploy.sh únicamente si el gate salió con código 0. Si el gate sale con ≠ 0, el && se corta (no despliega) y el || dispara el mensaje de bloqueo. Es la misma lógica que un pipeline de CI aplica entre el paso de pruebas y el de deploy, expresada en una línea de shell.
Resumen y siguiente paso
El código de salida es lo que le da dientes al veredicto. Cuando un programa termina deja un número: 0 = éxito, cualquier otro = fallo, y ese número es el idioma universal con el que un programa le habla a un pipeline de CI. En Python lo fijas con sys.exit(0) / sys.exit(1) y el shell lo lee en $?. Tu gate convierte el booleano del veredicto en ese código —True → 0, False → 1— y lo viste ejecutado: $? = 0 con carga liviana, $? = 1 con carga pesada (aunque solo una regla fallara). Un pipeline se detiene en el primer paso que sale con código ≠ 0, así que un gate que sale con 1 impide el deploy —lo comprobaste con la cadena && deploy || bloqueado del shell, que autorizó con la carga liviana y bloqueó con la pesada—.
k6 hace lo mismo: sale con el código 99 (ThresholdsHaveFailed) cuando un threshold falla. El 1 de Python y el 99 de k6 son ambos "distinto de cero", y eso es todo lo que el pipeline necesita para poner el build en rojo. Así "el rendimiento" se vuelve tan binario y automatizable como un test unitario: una regresión de latencia reprueba el build igual que un test roto.
Antes de avanzar deberías poder: explicar qué es un código de salida y qué leen $? y el pipeline; distinguir un gate real (sale con ≠ 0 en fallo) de uno decorativo (solo imprime); y encadenar gate y deploy con &&. Lo que sigue, en la lección 5, es la pregunta que hemos esquivado: ¿de dónde sale el número del umbral? ¿Por qué 200 ms y no 150 o 500? La respuesta no es técnica sino de negocio —los SLO/SLA— y elegir mal el umbral vuelve inútil todo este mecanismo tan preciso. Un gate perfecto con un umbral arbitrario no protege nada.
Recursos
sys.exit— documentación de Python — cómo un programa Python fija su código de salida (0 = éxito, ≠ 0 = fallo). El mecanismo con el que el gate le habla al pipeline.- k6 — Thresholds (el exit code) — confirma que un threshold roto hace que
k6 runsalga con un código distinto de cero, que hace fallar el paso de CI. - k6 — Error codes (código 99) — el código fuente de k6 donde se define
ThresholdsHaveFailed = 99, el código exacto que k6 devuelve cuando un threshold falla. La fuente del "99". - GitHub Actions — Exit codes y estado de los pasos — cómo un paso que sale con código ≠ 0 hace fallar el job y detiene el pipeline. El otro extremo del sello. El YAML completo es el módulo 7.