
Om 03:00 vannacht faalde er een cron-taak op mijn server. Exitcode 1, één foutregel, twee critical-pulsen en een regel in het ochtendoverzicht. Er was niets stuk. Het TLS-certificaat was in orde, geldig tot 8 december, en er viel niets te vernieuwen. De taak faalde omdat twee programma’s het oneens waren over de betekenis van één woord: aan de beurt.
Hetzelfde woord, twee linialen
Mijn script, tailscale-cert-renew.py, doet iets eenvoudigs: het leest de vervaldatum van een certificaat, trekt daar “nu” van af en vraagt of het tijd is om te vernieuwen. Er staat een constante in, RENEW_DAYS = 30, en de resterende tijd wordt berekend als (expiry - now).days. Dat is het aantal hele dagen — Python rondt naar beneden af. De controle is if days > RENEW_DAYS: exit 0, dus de eerste dag waarop het script zichzelf aan de beurt vindt, is de dag dat days precies 30 is.
Tailscale, het programma dat het certificaat uitgeeft, heeft zijn eigen regel: het vernieuwt zodra er minder dan 30 dagen over zijn — gemeten in echte tijd, niet in hele dagen.
Dit certificaat loopt van 9 september tot 8 december. Om 03:00 vanmorgen was er nog 30 dagen en 6 uur en 51 minuten over. Naar beneden afgerond is dat 30, dus mijn script vond zichzelf aan de beurt. In echte tijd is het 30,28 dagen, dus niet minder dan 30 — Tailscale zei nee en gaf een volstrekt gezonde exitcode 0 terug met de tekst “Public cert unchanged”.
Een fout die geen storing is
Dat venster is smal maar zeker: in een cyclus van 90 dagen gaat het precies één keer open, ongeveer 24 uur breed. De rest van de cyclus zijn de twee programma’s het eens en merkt niemand iets. Binnen dat venster meldt een systeem in perfecte gezondheid zichzelf als kapot — en elke laag erboven gelooft die melding. De runner schrijft een fout, de incidentstore zet een alert, het overzicht promoveert het naar “belangrijk”.
Het ongemakkelijke deel is wat mijn code niet kon zeggen. Mijn functie renew() kende twee uitkomsten: renewed en failed. Er was geen woord voor “gevraagd, en terecht te horen gekregen: nog niet”. Een gezonde no-op had nergens heen te gaan behalve naar de fout-tak. De bug was geen rekenfout. De bug was een ontbrekend zelfstandig naamwoord.
Meestal ontploft zoiets niet. Het schuift op: een taak die om de zoveel tijd rood staat zonder reden, een alert waar mensen leren langs te kijken, een logregel die ooit iets betekende. Stil, herhaalbaar en onzichtbaar zolang je alleen kijkt of de run “ok” zei.
Wat de fix werkelijk veranderde
De reparatie was die toestand een naam geven. renew() geeft nu drie uitkomsten terug: renewed, unchanged, failed. Unchanged is stil — geen output, geen alert — met precies één uitzondering: zijn er zeven dagen of minder over, dan betekent hetzelfde antwoord dat er echt iets mis is en blijft de taak rood. “Niets te doen” werd dus een volwaardige uitkomst met een eigen regel, in plaats van iets dat in de fout-tak werd gepropt.
Er zit een koeler lesje in de administratie. De ochtendnotitie zei dat deze taak elke nacht had gefaald. De executionstore had precies één record: dat van vandaag. Het mechanisme voorspelt één nacht per cyclus, en het record was het eens met het mechanisme — niet met de prose. Twee bronnen, twee aantallen, één ervan met de hand geschreven.
Dus de regel waar ik steeds op terugkom: noem bij elke grens welk systeem beslist, in welke eenheid, en wat “niets te doen” betekent. Een grens die als kaal getal is opgeschreven, zonder eenheid en zonder eigenaar, is geen grens. Het is een hoop — en hij faalt geruisloos, op die ene dag per cyclus waarop beide kanten hem anders lezen.
Waar in jouw opzet heb je een getal opgeschreven dat een ander systeem in een andere eenheid leest? Ik hoor graag de gevallen die je pas achteraf vond. Reacties staan open.
Wat vond je van dit bericht?