Módulo 5: The Incident Lifecycle
7. Manos a la obra: una rotación de on-call determinista
Descripción
Esta lección construye oncall/schedule.py: un generador de rotación de guardia semanal para Andes Cargo, sin ningún SaaS, sin ninguna dependencia externa, y —a diferencia de casi cualquier ejemplo de rotación que encontrarías en un tutorial genérico— sin ninguna aleatoriedad. La rotación es una función pura de una lista fija de personas y una fecha ancla fija: correr el script hoy, o correrlo dentro de un año, produce exactamente el mismo resultado para la misma semana. Es la aplicación directa de la lección 6: una rotación tan simple y portable que exportarla a cualquier formato o herramienta es trivial.
Conexión con el módulo
Esta es la primera y única pieza de este módulo con infraestructura de código real detrás —Python puro, ejecutado de verdad—. El resultado de esta lección, las primeras semanas de rotación, se copia sin cambios a la sección "On-call rotation" de INCIDENT-RESPONSE-PLAN.md en la lección 8, exactamente el mismo patrón que este ecosistema ya usó con scripts/error_budget_calculator.py (Módulo 2) y scripts/burn_rate_evaluator.py (Módulo 4).
Paso 1 — Por qué nunca random, nunca datetime.now()
Dos decisiones de diseño, ninguna accidental. Una rotación generada con random.choice() no es verificable — nadie puede confirmar, mirando el código, quién estará de guardia la semana 12 sin ejecutar el script y confiar en que el generador de números aleatorios no cambió de comportamiento entre ejecuciones. Una rotación anclada en datetime.now() es peor todavía: correr el mismo script hoy y dentro de una semana produciría resultados distintos para lo que debería ser la misma semana fija del calendario, porque "hoy" cambia cada vez que se ejecuta. Ninguna de las dos propiedades es aceptable para un documento de guardia que un equipo real necesita poder auditar, línea por línea, meses después de escrito — la misma regla dura que gobierna cada script de esta guía desde el Módulo 2.
La alternativa que este script usa: una lista fija de personas (ROSTER), y una fecha ancla fija (START_MONDAY), ambas escritas directamente en el código, nunca calculadas a partir del reloj del sistema.
Paso 2 — El script completo
Crea oncall/schedule.py en la raíz de andes-cargo-infra/:
# schedule.py
# Deterministic weekly on-call rotation for Andes Cargo. No SaaS, no randomness: a fixed
# roster, rotated by index, over a fixed set of example weeks -- reproducible, auditable,
# and portable to any tool (Module 5, lesson 6 on switching costs).
from datetime import date, timedelta
# Fixed roster, alphabetical order -- the starting order is a decision, not an accident
# (this lesson's Step 4 explains why the order matters and how it stays fair over time).
ROSTER = ["Ana", "Bruno", "Carla", "Diego"]
# Fixed anchor Monday. Never datetime.now() -- a schedule generated from "today" is not
# reproducible: running this script twice on two different days would silently produce
# two different schedules for the exact same week.
START_MONDAY = date(2026, 3, 2)
WEEKS_TO_GENERATE = 8
def primary_for_week(week_index):
"""Primary on-call, rotating through ROSTER once per week."""
return ROSTER[week_index % len(ROSTER)]
def secondary_for_week(week_index):
"""Secondary/backup on-call: always the NEXT person in ROSTER after primary --
guarantees the backup is never the same person as primary, deterministically."""
return ROSTER[(week_index + 1) % len(ROSTER)]
def week_start(week_index):
return START_MONDAY + timedelta(weeks=week_index)
def build_schedule(weeks=WEEKS_TO_GENERATE):
schedule = []
for week_index in range(weeks):
schedule.append(
{
"week": week_index + 1,
"starts": week_start(week_index).isoformat(),
"primary": primary_for_week(week_index),
"secondary": secondary_for_week(week_index),
}
)
return schedule
def print_schedule(schedule):
print(f"{'Week':<6}{'Starts':<12}{'Primary':<10}{'Secondary':<10}")
for row in schedule:
print(f"{row['week']:<6}{row['starts']:<12}{row['primary']:<10}{row['secondary']:<10}")
if __name__ == "__main__":
print_schedule(build_schedule())
Cada función hace exactamente una cosa. primary_for_week() usa el operador módulo (%) sobre el índice de la semana y el tamaño de ROSTER — el mismo patrón de "envolver alrededor de la lista" que produce una rotación cíclica sin necesitar ninguna estructura más compleja que una lista y un índice. secondary_for_week() reutiliza la misma lógica, desplazada una posición, garantizando por construcción —nunca por una verificación adicional— que el respaldo nunca coincide con el titular. week_start() calcula la fecha de inicio de cualquier semana sumando semanas completas (timedelta(weeks=...)) a la fecha ancla fija, sin ninguna dependencia del reloj del sistema.
Paso 3 — Corriendo el generador
python3 oncall/schedule.py
Qué esperar (literal — corrido dos veces produce, línea por línea, el mismo resultado, verificado para esta lección):
Week Starts Primary Secondary
1 2026-03-02 Ana Bruno
2 2026-03-09 Bruno Carla
3 2026-03-16 Carla Diego
4 2026-03-23 Diego Ana
5 2026-03-30 Ana Bruno
6 2026-04-06 Bruno Carla
7 2026-04-13 Carla Diego
8 2026-04-20 Diego Ana
Ocho semanas, el ciclo completo de ROSTER (cuatro personas) repetido exactamente dos veces. La semana 5 vuelve a ser idéntica, en titular y respaldo, a la semana 1 — la prueba visual de que el patrón es cíclico, con periodo igual al tamaño de la lista.
Paso 4 — Por qué el orden alfabético inicial es una decisión, no un accidente
ROSTER empieza en orden alfabético (Ana, Bruno, Carla, Diego) por una razón concreta: es el criterio más fácil de auditar sin ninguna ambigüedad — cualquier persona nueva en el equipo puede verificar, sin preguntarle a nadie, por qué el orden es ese y no otro. La alternativa —ordenar por antigüedad, por preferencia personal, o "como se nos ocurrió"— introduce una decisión subjetiva adicional que, con el tiempo, alguien va a cuestionar ("¿por qué Diego siempre es el último de cada ciclo?"). El orden alfabético no resuelve el problema de fondo —alguien siempre tiene que ser el primero y alguien siempre el último dentro de cada ciclo—, pero sí elimina cualquier sospecha de favoritismo en cómo se decidió ese orden.
Paso 5 — Un paso más: por qué este formato ya es portable
La lección 6 de este módulo citó la recomendación de tratar el enrutamiento de guardia como una capa portable, con horarios en un formato simple como YAML. build_schedule() ya produce exactamente eso — una lista de diccionarios simples, sin ninguna dependencia de una API o interfaz propietaria —, así que exportarla a un formato de intercambio real es una sola línea:
import json
print(json.dumps(build_schedule()[0], indent=2))
Qué esperar (literal, verificado para esta lección):
{
"week": 1,
"starts": "2026-03-02",
"primary": "Ana",
"secondary": "Bruno"
}
Esta es, precisamente, la propiedad que la cita de Hacker News de la lección 6 recomienda: los datos reales de la rotación —quién, cuándo— nunca quedaron atrapados dentro de la lógica interna de un proveedor específico. Migrar esta misma información hacia PagerDuty, Opsgenie, o cualquier otra herramienta el día de mañana es un problema de formato de importación, no un problema de reconstruir la lógica de rotación desde cero.
Errores comunes
Reemplazar week_index % len(ROSTER) con random.choice(ROSTER) "para que se sienta menos predecible" (de romper la determinación a propósito). Qué pasa: alguien, incómodo con que la rotación sea tan fácil de predecir con anticipación, decide introducir aleatoriedad para que "no sea obvio quién sigue". Cómo detectarlo: si tu versión de schedule.py importa el módulo random en cualquier parte. Cómo corregirlo: que la rotación sea predecible es la característica, no un defecto — un equipo real necesita poder mirar el calendario y saber, con semanas de anticipación, cuándo le toca a cada quien, exactamente la misma necesidad de auditabilidad que el resto de esta guía exige de cada script desde el Módulo 2. "Predecible" y "justo" no son opuestos; de hecho, la predictibilidad es lo que hace posible verificar la justicia del reparto.
Agregar una persona nueva a ROSTER a mitad de un ciclo ya en curso, sin considerar qué pasa con las semanas ya generadas (de romper la continuidad de la rotación). Qué pasa: alguien agrega un quinto nombre a la lista después de que ya se comunicaron las primeras cuatro semanas de guardia, sin darse cuenta de que eso cambia, retroactivamente, quién le toca a partir de la semana en que se hizo el cambio (week_index % 5 produce un patrón completamente distinto a week_index % 4 para casi todas las semanas). Cómo detectarlo: si comparas la salida del script antes y después de agregar una persona, y las semanas que ya se habían comunicado ya no coinciden. Cómo corregirlo: esta es una limitación real y honesta de este script, no un defecto oculto — schedule.py, en su forma actual, asume un ROSTER estable durante todo el rango de semanas generado. Cambiar el roster a mitad de camino requiere, como mínimo, fijar explícitamente a partir de qué semana aplica el cambio, en vez de simplemente editar la lista y volver a correr el script sobre el rango completo.
Asumir que secondary_for_week() garantiza una distribución perfectamente justa de quién respalda a quién (de sobre-confiar en el diseño sin verificarlo). Qué pasa: alguien concluye que, porque el respaldo nunca coincide con el titular, la carga de "ser respaldo" está distribuida de forma completamente pareja entre las cuatro personas. Cómo detectarlo: si nunca revisaste qué pareja específica de (titular, respaldo) se repite en cada ciclo completo. Cómo corregirlo: con este diseño, cada persona respalda siempre a la misma persona que la precede en ROSTER (Bruno siempre respalda a Ana, Carla siempre respalda a Bruno, y así sucesivamente) — nunca cambia de pareja. Es una limitación real, honesta, del diseño más simple posible: garantiza que titular y respaldo nunca coinciden, pero no garantiza variedad en las parejas. Una versión más sofisticada podría rotar también el desplazamiento del respaldo, a costa de un patrón más difícil de predecir a simple vista — el mismo tipo de trade-off, explícito, que el resto de esta guía ya declaró en cada caso representativo.
Ejercicios
Ejercicio 1 — Sin ejecutar el script, calcula a mano quién sería el titular y el respaldo de la semana 12, usando week_index % len(ROSTER). Recuerda que week_index empieza en 0 para la semana 1.
Ver solución
La semana 12 corresponde a week_index = 11 (semana 1 = índice 0, así que semana 12 = índice 11). primary_for_week(11) = ROSTER[11 % 4] = ROSTER[3] = "Diego". secondary_for_week(11) = ROSTER[(11 + 1) % 4] = ROSTER[12 % 4] = ROSTER[0] = "Ana". Titular: Diego. Respaldo: Ana — exactamente el mismo par que la semana 4 y la semana 8 de la tabla ya generada (11 % 4 == 3, el mismo resto que 3 % 4 y 7 % 4), confirmando el patrón cíclico de periodo 4.
Ejercicio 2 — Explica por qué START_MONDAY está escrito como una fecha fija (date(2026, 3, 2)) en vez de calcularse como "el próximo lunes a partir de hoy". ¿Qué problema concreto evita esta decisión?
Ver solución
Calcular "el próximo lunes a partir de hoy" requeriría usar date.today() o datetime.now(), lo que rompería la determinación completa del script: ejecutarlo un martes produciría una fecha ancla distinta que ejecutarlo un jueves de la misma semana, y la "semana 1" de la rotación cambiaría cada vez que alguien corriera el script en un día distinto. Con una fecha fija escrita directamente en el código, la semana 1 siempre empieza el 2 de marzo de 2026, sin importar qué día sea "hoy" quien ejecute el script — la misma regla dura que gobierna cada dataset fijo de esta guía desde el Módulo 2 (TRAFFIC_30_DAYS, BAD_WEEK): nunca calcular a partir del reloj del sistema lo que debe ser reproducible.
Ejercicio 3 — Modifica, en prosa (sin ejecutar nada), qué línea de schedule.py cambiarías para agregar una quinta persona, "Elena", al final del roster, y predice cómo cambiaría el titular de la semana 5.
Ver solución
Bastaría con cambiar la línea ROSTER = ["Ana", "Bruno", "Carla", "Diego"] a ROSTER = ["Ana", "Bruno", "Carla", "Diego", "Elena"] — ninguna otra función necesitaría cambios, porque primary_for_week() y secondary_for_week() ya usan len(ROSTER) de forma genérica, nunca el número 4 escrito directamente. Con cinco personas, la semana 5 (week_index = 4) pasaría de ser ROSTER[4 % 4] = ROSTER[0] = "Ana" (con cuatro personas) a ROSTER[4 % 5] = ROSTER[4] = "Elena" (con cinco) — el ciclo completo ahora tarda cinco semanas en repetirse, no cuatro, y cada persona pasa de estar de guardia primaria una semana de cada cuatro (25%, el límite exacto de Google SRE) a una semana de cada cinco (20%, por debajo del límite, con más margen).
Resumen y siguiente paso
Esta lección construyó y corrió oncall/schedule.py: una rotación semanal determinista, sin SaaS, sin aleatoriedad, ni dependencia del reloj del sistema — un ROSTER fijo de cuatro personas y una fecha ancla fija (2026-03-02) producen ocho semanas de titular y respaldo, verificadas idénticas en dos corridas independientes. Confirmaste, con la salida real del script, que el patrón se repite exactamente cada cuatro semanas, y viste cómo exportar la primera semana a un formato portable (JSON) en una sola línea — la aplicación directa de la lección 6 sobre por qué el on-call debe diseñarse portable desde el primer día.
Antes de avanzar deberías poder: correr el script y obtener exactamente los mismos números de esta lección; calcular a mano el titular y el respaldo de cualquier semana futura, usando el operador módulo; y explicar por qué START_MONDAY nunca se calcula a partir de datetime.now().
La lección 8, el proyecto final de este módulo, reúne el ciclo de vida (lección 2), la matriz de severidad (lección 5), los roles (lección 4), y esta misma rotación en el documento completo: INCIDENT-RESPONSE-PLAN.md.
Recursos
- Este mismo repositorio, Módulo 5, lección 6 (
06-on-call-with-honesty-about-its-cost.md) — la cita de Hacker News que motiva el diseño portable de este script. - Este mismo repositorio, Módulo 1, lección 7 (
07-the-vocabulary-youll-use-all-guide.md) — el límite del 25% de tiempo en guardia, citado de nuevo en el Paso 4 de esta lección. - Este mismo repositorio, Módulo 2, lecciones 4 y 7 — el mismo patrón de datos fijos, deterministas, nunca calculados desde
datetime.now(), que esta lección aplica por primera vez a una rotación de personas en vez de a tráfico de un sistema. - Python — documentación oficial del módulo
datetime— la referencia dedateytimedeltausados en este script.