<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Automation on ErrorZap</title><link>https://errorzap.com/tags/automation/</link><description>Recent content in Automation on ErrorZap</description><image><title>ErrorZap</title><url>https://errorzap.com/og.png</url><link>https://errorzap.com/og.png</link></image><generator>Hugo</generator><language>en-US</language><copyright>ErrorZap</copyright><lastBuildDate>Tue, 09 Jun 2026 00:00:00 -0600</lastBuildDate><atom:link href="https://errorzap.com/tags/automation/index.xml" rel="self" type="application/rss+xml"/><item><title>The Alert That Went to Nobody</title><link>https://errorzap.com/posts/the-alert-that-went-to-nobody/</link><pubDate>Tue, 09 Jun 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/the-alert-that-went-to-nobody/</guid><description>A critical event fired a perfectly good alert into a receiver wired to absolutely no one — and everybody slept fine because of it.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="The Alert That Went to Nobody" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>A customer called on a Tuesday. A critical service had been down for hours. Their first question was the one that ruins your week:</p>
<p>&ldquo;Why didn&rsquo;t we get an alert?&rdquo;</p>
<p>I had built the alerting. I knew the rules existed. I&rsquo;d watched them fire in testing months ago. So my honest, confident, completely wrong answer was: &ldquo;You should have.&rdquo;</p>
<p>That&rsquo;s the trap. &ldquo;Should have&rdquo; is what you say right before you learn something humbling about your own stack.</p>
<h2 id="the-investigation">The investigation</h2>
<p>First instinct: the rule didn&rsquo;t fire. Easy theory, easy to check. So I pulled the alert history.</p>
<p>The rule fired. On time. With the right severity, the right labels, the right everything. It did its job flawlessly and then handed the alert off to&hellip; something.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>┌──────────┐    ┌────────────┐    ┌──────────┐    ┌────────┐
</span></span><span style="display:flex;"><span>│  RULE    │──► │  RECEIVER  │──► │ CHANNEL  │──► │ HUMAN  │
</span></span><span style="display:flex;"><span>│ ✓ fired  │    │ ✓ matched  │    │  ✗ none  │    │  ✗ —   │
</span></span><span style="display:flex;"><span>└──────────┘    └────────────┘    └──────────┘    └────────┘
</span></span><span style="display:flex;"><span>                                   ▲
</span></span><span style="display:flex;"><span>                                   │
</span></span><span style="display:flex;"><span>                          the chain dies right here
</span></span></code></pre></div><p>The rule routed to a receiver. The receiver had a name, a config block, a place in the tree. It looked alive. And its list of channels — the email addresses, the webhook URLs, the push targets, whatever was supposed to carry the message to a person — was empty.</p>
<p>Not broken. Not misconfigured. <strong>Empty.</strong> The alert was delivered, perfectly, to nobody.</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="An alert envelope flying into an empty mailbox">
  <rect x="0" y="0" width="560" height="220" rx="10" fill="#11111b"/>
  <text x="280" y="30" text-anchor="middle" fill="#cdd6f4" font-family="monospace" font-size="15">rule → receiver → channel → ?</text>
  <!-- envelope -->
  <g>
    <rect x="60" y="92" width="96" height="64" rx="6" fill="#1e1e2e" stroke="#cba6f7" stroke-width="2"/>
    <path d="M60 98 L108 130 L156 98" fill="none" stroke="#cba6f7" stroke-width="2"/>
    <text x="108" y="180" text-anchor="middle" fill="#cba6f7" font-family="monospace" font-size="12">ALERT</text>
  </g>
  <!-- flight path -->
  <path d="M170 124 Q300 70 400 124" fill="none" stroke="#fab387" stroke-width="2" stroke-dasharray="6 6"/>
  <polygon points="400,124 388,116 390,132" fill="#fab387"/>
  <!-- empty mailbox -->
  <g>
    <rect x="408" y="96" width="96" height="60" rx="8" fill="#1e1e2e" stroke="#45475a" stroke-width="2"/>
    <rect x="408" y="96" width="96" height="20" rx="8" fill="#313244"/>
    <text x="456" y="138" text-anchor="middle" fill="#6c7086" font-family="monospace" font-size="13">[ empty ]</text>
    <line x1="456" y1="156" x2="456" y2="184" stroke="#45475a" stroke-width="3"/>
  </g>
<p><text x="456" y="206" text-anchor="middle" fill="#f38ba8" font-family="monospace" font-size="12">no recipients ✗</text>
</svg></p>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">The mail always went out. There was just no mailbox at the end of the route.</figcaption>
</figure>
<h2 id="the-aha">The &ldquo;aha&rdquo;</h2>
<p>Here&rsquo;s the part that stung. Everyone — me included — had validated the wrong layer.</p>
<p>We tested that rules existed. We tested that rules fired. We never tested that a fired alert reached a phone, an inbox, a pager, a chat channel — a <em>person</em>. The chain has four links; we&rsquo;d verified two and assumed the other two by vibes.</p>
<p>An alert is not a notification. A rule firing is not someone finding out. The gap between those two facts is exactly where this outage lived — silent, for months — manufacturing a warm, false confidence that the system &ldquo;had alerting.&rdquo;</p>
<p>It did. The alerting just talked to a wall.</p>
<h2 id="the-fix">The fix</h2>
<p>Audit every receiver. Every single one needs at least one channel, and that channel has to actually reach a human or a device. No empties, no dead webhooks, no stale email lists.</p>
<p>Then — and this is the part people skip — add a heartbeat. A &ldquo;dead-man&rdquo; alert that is <em>supposed</em> to fire on a schedule. If it stops arriving, your delivery path is broken and you find out on purpose instead of during a customer call.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># 1. Find every receiver with an empty / missing channel list.</span>
</span></span><span style="display:flex;"><span>amtool config routes show
</span></span><span style="display:flex;"><span>amtool config show | yq <span style="color:#f1fa8c">&#39;.receivers[] | select(
</span></span></span><span style="display:flex;"><span><span style="color:#f1fa8c">  ([.email_configs, .webhook_configs, .pushover_configs,
</span></span></span><span style="display:flex;"><span><span style="color:#f1fa8c">    .slack_configs, .opsgenie_configs] | flatten | length) == 0
</span></span></span><span style="display:flex;"><span><span style="color:#f1fa8c">) | .name&#39;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># 2. End-to-end test: fire a synthetic alert and confirm it lands on a device.</span>
</span></span><span style="display:flex;"><span>amtool alert add deadmans_switch <span style="color:#f1fa8c">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#8be9fd;font-style:italic">severity</span><span style="color:#ff79c6">=</span>info <span style="color:#8be9fd;font-style:italic">service</span><span style="color:#ff79c6">=</span>alerting <span style="color:#f1fa8c">\
</span></span></span><span style="display:flex;"><span>  --annotation <span style="color:#8be9fd;font-style:italic">summary</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">&#34;heartbeat — if you can read this, delivery works&#34;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#6272a4"># 3. The dead-man heartbeat: a rule that is ALWAYS firing on purpose.</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">groups</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ff79c6">name</span>: heartbeat
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">rules</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ff79c6">alert</span>: DeadMansSwitch
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">expr</span>: vector(1)
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">labels</span>: { <span style="color:#ff79c6">severity</span>: heartbeat }
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">annotations</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#ff79c6">summary</span>: <span style="color:#f1fa8c">&#34;Alerting pipeline is alive. Silence = it isn&#39;t.&#34;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>DELIVERY CHAIN ── post-fix
</span></span><span style="display:flex;"><span>  RULE ─► RECEIVER ─► CHANNEL ─► DEVICE
</span></span><span style="display:flex;"><span>   ✓        ✓          ✓ (≥1)     ✓ buzzed
</span></span><span style="display:flex;"><span>        + DeadMansSwitch heartbeat every 60s ►► if it goes quiet, you know
</span></span></code></pre></div><p>Flip the logic. A normal alert proves a problem exists. A heartbeat proves the <em>path itself</em> still works. You want both, because the path is the thing that fails silently.</p>
<h2 id="why-it-happened">Why it happened</h2>
<p>Someone — possibly past me — created the receiver as a placeholder, fully intending to fill in the channels later. Later never came. The config was syntactically valid, so nothing complained. There&rsquo;s no error on an empty channel list; it&rsquo;s a legal, boring state.</p>
<p>And because the rules were visibly there, every review after that pattern-matched &ldquo;alerting: configured ✓&rdquo; and moved on. Nobody walked the whole chain to a buzzing phone. The placeholder calcified into production.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>An alert with no destination is worse than no alert.</strong> No alerting at least keeps you honest. A dead receiver hands you false confidence and bills you for it during an outage.</li>
<li><strong>Validate the entire chain: rule → receiver → channel → device.</strong> Verifying any single link tells you almost nothing about the link after it.</li>
<li><strong>Test delivery, not configuration.</strong> &ldquo;The rule exists&rdquo; and &ldquo;a human got paged&rdquo; are different claims. Only the second one matters at 2 a.m.</li>
<li><strong>Add a dead-man heartbeat.</strong> A signal that <em>should</em> arrive on schedule turns silent failure into a loud, on-purpose one.</li>
<li><strong>Audit receivers for empties on a cadence.</strong> Placeholders, decommissioned inboxes, and rotted webhooks accumulate. Grep them out before they grep you.</li>
</ul>
]]></content:encoded></item><item><title>The API Loop That Ran Up the Bill Overnight</title><link>https://errorzap.com/posts/the-api-loop-that-ran-up-the-bill-overnight/</link><pubDate>Mon, 18 May 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/the-api-loop-that-ran-up-the-bill-overnight/</guid><description>An unattended automation found a paid LLM endpoint, lost its mind, and hammered it thousands of times while everyone slept.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="The API Loop That Ran Up the Bill Overnight" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>The cron job ran at 11 PM. By the time I poured coffee the next morning, it had called a paid LLM API a few thousand times.</p>
<p>Nobody touched it. Nobody approved it. It just sat there in the dark, retrying, retrying, retrying — like a vending machine eating a stuck dollar bill, except the dollar bill was billable tokens.</p>
<p>I didn&rsquo;t find it because of a clever alert. I found it because the provider dashboard had a graph that looked like a cliff face.</p>
<h2 id="the-scene">The scene</h2>
<p>I run a small fleet of unattended automations for a client. One of them enriches records overnight by sending them through a metered LLM endpoint. Cheap per call. Boring. The kind of job you set up once and forget — which is exactly the problem.</p>
<p>That night, the upstream service it depended on got flaky. A handful of calls returned errors. The loop did what badly-written loops always do.</p>
<p>It tried again. Immediately. Forever.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>┌────────────────────────────────────────────────────┐
</span></span><span style="display:flex;"><span>│  THE LOOP (as written)                             │
</span></span><span style="display:flex;"><span>│                                                    │
</span></span><span style="display:flex;"><span>│   ┌──────────┐   error   ┌──────────────┐          │
</span></span><span style="display:flex;"><span>│   │ call API │ ────────► │ retry now    │          │
</span></span><span style="display:flex;"><span>│   └────┬─────┘           └──────┬───────┘          │
</span></span><span style="display:flex;"><span>│        ▲                        │                  │
</span></span><span style="display:flex;"><span>│        └────────────────────────┘                  │
</span></span><span style="display:flex;"><span>│        no backoff · no cap · no kill switch        │
</span></span><span style="display:flex;"><span>│                                                    │
</span></span><span style="display:flex;"><span>│   result: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓  thousands of hits │
</span></span><span style="display:flex;"><span>└────────────────────────────────────────────────────┘
</span></span></code></pre></div><p>No <code>sleep</code>. No max-retry counter. No circuit breaker. Every failure became an instant request, and every instant request became another line on the invoice.</p>
<h2 id="the-investigation">The investigation</h2>
<p>First thing: confirm it&rsquo;s us and not a stolen key. I pulled the provider&rsquo;s usage view and bucketed by hour.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># usage exploded between 23:00 and 07:00 — exactly the cron window</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># logs from the job host told the same story</span>
</span></span><span style="display:flex;"><span>journalctl -u record-enrich.service --since <span style="color:#f1fa8c">&#34;yesterday 23:00&#34;</span> <span style="color:#f1fa8c">\
</span></span></span><span style="display:flex;"><span>  | grep -c <span style="color:#f1fa8c">&#34;POST /v1/&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># -&gt; 4,300-something. Overnight. For a job that should make ~80 calls.</span>
</span></span></code></pre></div><p>Same source IP. Same user-agent. Same service. Not a breach — a self-inflicted wound. Somehow that&rsquo;s worse, because it means the call was coming from inside the house.</p>
<h2 id="the-aha">The &ldquo;aha&rdquo;</h2>
<p>The smoking gun was four lines of code. Paraphrased:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#ff79c6">while</span> <span style="color:#ff79c6">not</span> done:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">try</span>:
</span></span><span style="display:flex;"><span>        resp <span style="color:#ff79c6">=</span> client<span style="color:#ff79c6">.</span>complete(payload)
</span></span><span style="display:flex;"><span>        done <span style="color:#ff79c6">=</span> <span style="color:#ff79c6">True</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">except</span> Exception:
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">continue</span>   <span style="color:#6272a4"># &lt;-- the whole disaster, right here</span>
</span></span></code></pre></div><p><code>continue</code>. No delay. No ceiling. The instant the upstream hiccuped, this turned into a tight spin loop firing paid requests as fast as the network would carry them. A retry without backoff isn&rsquo;t resilience — it&rsquo;s a denial-of-wallet attack you launch against yourself.</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Spend rising overnight until a quota cap flattens it">
  <rect x="0" y="0" width="560" height="220" rx="10" fill="#11111b"/>
  <rect x="0" y="0" width="560" height="220" rx="10" fill="#1e1e2e" opacity="0.5"/>
  <line x1="60" y1="30" x2="60" y2="180" stroke="#45475a" stroke-width="1.5"/>
  <line x1="60" y1="180" x2="520" y2="180" stroke="#45475a" stroke-width="1.5"/>
  <text x="60" y="22" fill="#6c7086" font-family="monospace" font-size="11">$ spend</text>
  <text x="470" y="198" fill="#6c7086" font-family="monospace" font-size="11">time →</text>
  <polyline points="60,176 150,172 230,168 300,150 340,110 365,60 380,40" fill="none" stroke="#f38ba8" stroke-width="3"/>
  <line x1="380" y1="40" x2="520" y2="40" stroke="#fab387" stroke-width="2" stroke-dasharray="6 5"/>
  <text x="384" y="33" fill="#fab387" font-family="monospace" font-size="11">hard quota cap</text>
  <circle cx="380" cy="40" r="4.5" fill="#cba6f7"/>
  <text x="120" y="150" fill="#89b4fa" font-family="monospace" font-size="11">23:00 cron starts</text>
  <text x="300" y="200" fill="#a6e3a1" font-family="monospace" font-size="11">07:00 caught ✓</text>
</svg>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">Cost climbs unattended all night; a provider-side quota cap is the only thing that would have flattened it before morning.</figcaption>
</figure>
<h2 id="the-fix">The fix</h2>
<p>I treated the key as compromised even though it wasn&rsquo;t, because the behavior was indistinguishable from a leak. Containment first, blame later.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#6272a4"># 1. Rotate the key immediately — old one dies on the spot</span>
</span></span><span style="display:flex;"><span>provider keys rotate --name record-enrich --revoke-old
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># 2. Disable the API entirely at the provider while I fix the code.</span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">#    Kill the bleeding before patching the artery.</span>
</span></span><span style="display:flex;"><span>provider api disable --service record-enrich
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4"># 3. Re-enable WITH a hard cap + budget alert. The cap is the real fix.</span>
</span></span><span style="display:flex;"><span>provider budget <span style="color:#8be9fd;font-style:italic">set</span> --service record-enrich <span style="color:#f1fa8c">\
</span></span></span><span style="display:flex;"><span>  --hard-limit-usd <span style="color:#bd93f9">25</span> --period monthly
</span></span><span style="display:flex;"><span>provider alerts <span style="color:#8be9fd;font-style:italic">set</span> --service record-enrich <span style="color:#f1fa8c">\
</span></span></span><span style="display:flex;"><span>  --notify-at 50% --notify-at 90% --channel telegram
</span></span></code></pre></div><p>Then the code got the guardrails it should have shipped with:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#ff79c6">import</span> time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>MAX_RETRIES <span style="color:#ff79c6">=</span> <span style="color:#bd93f9">5</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">for</span> attempt <span style="color:#ff79c6">in</span> <span style="color:#8be9fd;font-style:italic">range</span>(MAX_RETRIES):
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">try</span>:
</span></span><span style="display:flex;"><span>        resp <span style="color:#ff79c6">=</span> client<span style="color:#ff79c6">.</span>complete(payload)
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">break</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">except</span> TransientError:
</span></span><span style="display:flex;"><span>        time<span style="color:#ff79c6">.</span>sleep(<span style="color:#8be9fd;font-style:italic">min</span>(<span style="color:#bd93f9">2</span> <span style="color:#ff79c6">**</span> attempt, <span style="color:#bd93f9">30</span>))   <span style="color:#6272a4"># exponential backoff, capped</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">else</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">raise</span> RuntimeError(<span style="color:#f1fa8c">&#34;gave up after retries — failing loud, not looping&#34;</span>)
</span></span></code></pre></div><p>Backoff. A retry ceiling. And a loud failure instead of a silent infinite spin. The provider cap is the seatbelt; this is actually steering the car.</p>
<h2 id="why-it-happened">Why it happened</h2>
<p>It happened because &ldquo;it&rsquo;s just a small overnight job&rdquo; is the exact mindset that ships uncapped loops at paid endpoints. The cost-per-call was trivial, so nobody did the multiplication. Trivial times infinity is still a number you have to pay.</p>
<p>The code had no concept of &ldquo;too many.&rdquo; The provider had no concept of &ldquo;enough.&rdquo; With neither a ceiling in the app nor a ceiling at the wallet, the only limiter left was how fast the network could move — and that&rsquo;s a throttle on the damage, not a budget.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>Put a hard spend cap at the provider.</strong> It&rsquo;s the only limit a runaway loop can&rsquo;t out-code. Set it before you write the first request.</li>
<li><strong>Backoff and a max-retry count are not optional.</strong> A retry without delay or ceiling is a self-DoS. <code>continue</code> on an exception is a loaded gun.</li>
<li><strong>Wire usage alerts at 50% and 90%.</strong> You want a phone buzz at midnight, not a graph shaped like a cliff at breakfast.</li>
<li><strong>Treat weird usage as a leak until proven otherwise.</strong> Rotate the key and disable the endpoint first; debug the code second. Containment beats curiosity.</li>
<li><strong>&ldquo;Small overnight job&rdquo; is a smell.</strong> Anything unattended that touches a metered API gets a kill switch, a cap, and an alert — or it doesn&rsquo;t get deployed.</li>
</ul>
]]></content:encoded></item><item><title>Making a Python Script a Real Service</title><link>https://errorzap.com/posts/making-a-python-script-a-real-service/</link><pubDate>Mon, 06 Apr 2026 00:00:00 -0600</pubDate><guid>https://errorzap.com/posts/making-a-python-script-a-real-service/</guid><description>The script worked perfectly — right up until I closed the terminal and watched it die.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="Making a Python Script a Real Service" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>The pager went off at 6:14 a.m. The morning sync hadn&rsquo;t run.</p>
<p>I knew that script. I <em>wrote</em> that script. It was good. It pulled data, massaged it, pushed it where it needed to go. It worked flawlessly every single time I ran it.</p>
<p>That was the problem. It only worked when <em>I</em> ran it.</p>
<h2 id="the-scene">The scene</h2>
<p>Here&rsquo;s the embarrassing truth about that automation: it lived inside an SSH session. I&rsquo;d connect to the box at <code>sync-host.internal.example</code>, fire it off, watch it succeed, and disconnect feeling like a wizard.</p>
<p>The moment my terminal closed, the kernel reaped it. SIGHUP, lights out. No process, no cron-shaped safety net, nothing.</p>
<p>On the Windows box it was worse. I&rsquo;d &ldquo;scheduled&rdquo; it — except every run popped a console window that stole focus from whoever was using the machine. People started killing the window because it interrupted them. Can&rsquo;t blame them.</p>
<p>So three different failure modes, one script:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>   ┌──────────────────────────────────────────────┐
</span></span><span style="display:flex;"><span>   │  &#34;It works when I run it manually&#34;            │
</span></span><span style="display:flex;"><span>   ├──────────────────────────────────────────────┤
</span></span><span style="display:flex;"><span>   │  close SSH session   ──►  SIGHUP, process dies│
</span></span><span style="display:flex;"><span>   │  server reboots      ──►  nothing comes back  │
</span></span><span style="display:flex;"><span>   │  scheduled on win    ──►  window steals focus │
</span></span><span style="display:flex;"><span>   └──────────────────────────────────────────────┘
</span></span><span style="display:flex;"><span>                    │
</span></span><span style="display:flex;"><span>                    ▼
</span></span><span style="display:flex;"><span>            NOT actually deployed
</span></span></code></pre></div><h2 id="the-investigation">The investigation</h2>
<p>I tailed the journal. Nothing — because nothing was logging anywhere. The script wrote to stdout, and stdout was attached to a terminal that no longer existed.</p>
<p>I checked the process list. Empty. I checked for a cron entry. None — I&rsquo;d never made one, because &ldquo;I&rsquo;ll just run it&rdquo; had quietly become the deployment strategy.</p>
<p>That&rsquo;s the aha, and it&rsquo;s a dumb one: <strong>manual execution was masquerading as deployment.</strong> The script was perfect and completely undeployed at the same time. Every successful manual run was me hand-holding a service that had no manager.</p>
<p>A long-running, scheduled, headless job has three needs I&rsquo;d ignored: survive a logout, survive a reboot, and write its logs somewhere a human can read them later. A terminal gives you none of those.</p>
<h2 id="the-fix">The fix</h2>
<p>Give it a service manager. On Linux, a systemd unit. On Windows, NSSM. Both get auto-restart, log redirection, and start-on-boot — and both run headless.</p>
<p>Linux — <code>/etc/systemd/system/morning-sync.service</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#ff79c6">[Unit]</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">Description</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">Morning data sync</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">After</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">network-online.target</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">Wants</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">network-online.target</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">[Service]</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">Type</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">simple</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">User</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">svc-sync</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">WorkingDirectory</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">/opt/morning-sync</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">ExecStart</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">/opt/morning-sync/.venv/bin/python /opt/morning-sync/sync.py</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">Restart</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">on-failure</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">RestartSec</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">10</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">StandardOutput</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">append:/var/log/morning-sync/out.log</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">StandardError</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">append:/var/log/morning-sync/err.log</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">[Install]</span>
</span></span><span style="display:flex;"><span><span style="color:#50fa7b">WantedBy</span><span style="color:#ff79c6">=</span><span style="color:#f1fa8c">multi-user.target</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo mkdir -p /var/log/morning-sync
</span></span><span style="display:flex;"><span>sudo systemctl daemon-reload
</span></span><span style="display:flex;"><span>sudo systemctl <span style="color:#8be9fd;font-style:italic">enable</span> --now morning-sync.service
</span></span><span style="display:flex;"><span>systemctl status morning-sync.service
</span></span><span style="display:flex;"><span>journalctl -u morning-sync.service -f
</span></span></code></pre></div><p>Windows — same job, NSSM does the babysitting and runs it with no visible window:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bat" data-lang="bat"><span style="display:flex;"><span>nssm install MorningSync <span style="color:#f1fa8c">&#34;C:\apps\morning-sync\.venv\Scripts\python.exe&#34;</span> <span style="color:#f1fa8c">&#34;C:\apps\morning-sync\sync.py&#34;</span>
</span></span><span style="display:flex;"><span>nssm set MorningSync AppDirectory <span style="color:#f1fa8c">&#34;C:\apps\morning-sync&#34;</span>
</span></span><span style="display:flex;"><span>nssm set MorningSync AppStdout <span style="color:#f1fa8c">&#34;C:\logs\morning-sync\out.log&#34;</span>
</span></span><span style="display:flex;"><span>nssm set MorningSync AppStderr <span style="color:#f1fa8c">&#34;C:\logs\morning-sync\err.log&#34;</span>
</span></span><span style="display:flex;"><span>nssm set MorningSync AppExit Default Restart
</span></span><span style="display:flex;"><span>nssm set MorningSync Start SERVICE_AUTO_START
</span></span><span style="display:flex;"><span>nssm start MorningSync
</span></span></code></pre></div><p>Now I close my laptop and it keeps running. The box reboots at 3 a.m. for patches and the sync comes back on its own. The logs sit in a file instead of evaporating with my SSH session.</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Service manager supervising a Python script">
<rect x="0" y="0" width="560" height="220" rx="10" fill="#11111b"/>
<rect x="40" y="70" width="150" height="80" rx="10" fill="#1e1e2e" stroke="#45475a"/>
<text x="115" y="105" text-anchor="middle" fill="#cba6f7" font-family="monospace" font-size="14">service</text>
<text x="115" y="125" text-anchor="middle" fill="#6c7086" font-family="monospace" font-size="11">systemd / NSSM</text>
<rect x="370" y="70" width="150" height="80" rx="10" fill="#1e1e2e" stroke="#45475a"/>
<text x="445" y="105" text-anchor="middle" fill="#89b4fa" font-family="monospace" font-size="14">sync.py</text>
<text x="445" y="125" text-anchor="middle" fill="#6c7086" font-family="monospace" font-size="11">headless</text>
<line x1="190" y1="110" x2="370" y2="110" stroke="#a6e3a1" stroke-width="2"/>
<polygon points="370,110 358,104 358,116" fill="#a6e3a1"/>
<text x="280" y="100" text-anchor="middle" fill="#a6e3a1" font-family="monospace" font-size="11">start / supervise</text>
<path d="M 370 130 C 300 175, 260 175, 190 130" stroke="#fab387" stroke-width="2" fill="none"/>
<polygon points="190,130 202,128 196,139" fill="#fab387"/>
<text x="280" y="172" text-anchor="middle" fill="#fab387" font-family="monospace" font-size="11">Restart=on-failure</text>
<text x="280" y="40" text-anchor="middle" fill="#cdd6f4" font-family="monospace" font-size="13">boot ──► auto-start ──► logs to file</text>
<rect x="210" y="185" width="140" height="22" rx="6" fill="#313244" stroke="#45475a"/>
<text x="280" y="200" text-anchor="middle" fill="#94e2d5" font-family="monospace" font-size="11">survives logout + reboot</text>
</svg>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">A service manager turns "I ran it once" into "it runs forever."</figcaption>
</figure>
<h2 id="why-it-happened">Why it happened</h2>
<p>Because the script worked. That&rsquo;s it. When something works on the first manual run, the brain files it under &ldquo;done&rdquo; and quietly skips the boring part — the part where a supervisor owns the process, restarts it when it dies, and persists across boots.</p>
<p>A terminal is not a runtime. Cron-with-a-popup is not headless. &ldquo;Done&rdquo; is not &ldquo;deployed.&rdquo;</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>&ldquo;It works when I run it manually&rdquo; is not deployed.</strong> A successful manual run proves the logic, not the operation.</li>
<li><strong>Long-running automation belongs in a service manager</strong> — systemd on Linux, NSSM on Windows — not a terminal you forgot to close.</li>
<li><strong>Always set auto-restart and boot persistence.</strong> <code>Restart=on-failure</code> plus <code>enable --now</code> (or <code>SERVICE_AUTO_START</code>) means reboots and crashes self-heal.</li>
<li><strong>Redirect stdout/stderr to a file.</strong> Logs that live inside an SSH session die with the session, and you&rsquo;ll be debugging blind at 6 a.m.</li>
<li><strong>Run it headless.</strong> Background jobs that pop windows get killed by annoyed users. No UI, no focus theft, no problem.</li>
</ul>
]]></content:encoded></item><item><title>The Settings API That Corrupted Every Setting at Once</title><link>https://errorzap.com/posts/the-settings-api-that-corrupted-every-setting/</link><pubDate>Thu, 19 Feb 2026 00:00:00 -0700</pubDate><guid>https://errorzap.com/posts/the-settings-api-that-corrupted-every-setting/</guid><description>I changed one backup-retention field, ran it twice, and every client&amp;rsquo;s settings ate themselves like a snake swallowing its own tail.</description><content:encoded><![CDATA[<figure style="text-align:center;margin:0 0 30px"><img src="hero.png" alt="The Settings API That Corrupted Every Setting at Once" style="max-width:520px;width:100%;border-radius:14px"/></figure>
<p>It was supposed to be a five-minute job.</p>
<p>A client&rsquo;s backup server needed one field bumped on a handful of backup clients. Retention. One value. I had a Python wrapper sitting right there with a method named exactly what I wanted: <code>change_client_setting()</code>. Tab-complete practically begged me to use it.</p>
<p>So I looped over the clients and called it. Twice, because I tweaked a second field on the second pass.</p>
<p>Then the dashboard started showing clients with no backup schedule, no retention, no paths — like they&rsquo;d never been configured. Not one client. <strong>All</strong> of them.</p>
<h2 id="the-investigation">The investigation</h2>
<p>First instinct: did I fat-finger the loop? No. The script was boring. Read a name, set a field, move on.</p>
<p>Second instinct: did the service choke? Logs were clean. The server was happily <em>applying</em> the garbage I&rsquo;d handed it.</p>
<p>So I pulled the raw settings blob the API was actually POSTing. And there it was — the settings object, wrapped inside another settings object, wrapped inside <em>another</em> one. A JSON turducken.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>┌─────────────────────────────────────────────┐
</span></span><span style="display:flex;"><span>│ POST #1   { retention: 30, schedule: ... }   │  ✓ looks fine
</span></span><span style="display:flex;"><span>├─────────────────────────────────────────────┤
</span></span><span style="display:flex;"><span>│ POST #2   { settings: {                      │
</span></span><span style="display:flex;"><span>│              settings: {                      │
</span></span><span style="display:flex;"><span>│                retention: 30, schedule: ...   │  ✗ nested once
</span></span><span style="display:flex;"><span>│              } } }                            │
</span></span><span style="display:flex;"><span>├─────────────────────────────────────────────┤
</span></span><span style="display:flex;"><span>│ POST #3   { settings: { settings: {          │
</span></span><span style="display:flex;"><span>│              settings: { ...buried... } } } } │  ✗ nested twice → unreadable
</span></span><span style="display:flex;"><span>└─────────────────────────────────────────────┘
</span></span><span style="display:flex;"><span>                       ▼
</span></span><span style="display:flex;"><span>        server reads top level → finds nothing it recognizes
</span></span><span style="display:flex;"><span>        every real field is now three layers down → effectively gone
</span></span></code></pre></div><h2 id="the-aha">The &ldquo;aha&rdquo;</h2>
<p>Here&rsquo;s the part that made my stomach drop.</p>
<p><code>change_client_setting()</code> does <strong>not</strong> set one field. It round-trips the <em>entire</em> settings object: GET the whole blob, mutate the one key in memory, then re-serialize and POST the whole thing back.</p>
<p>That&rsquo;s already more blast radius than the name implies. But the killer was the encoder. Its re-serialization had a recursive-nesting bug — each round-trip tacked the existing object <em>inside</em> a fresh <code>settings</code> wrapper instead of replacing it.</p>
<p>One call: subtly wrong but survivable. Two calls: the real fields are buried deep enough that the server can&rsquo;t find them anymore. The defaults win. Every client looks wiped.</p>
<p>I didn&rsquo;t corrupt one setting. I corrupted the <em>container</em> every setting lives in — for every client — by changing a single number.</p>
<h2 id="the-fix">The fix</h2>
<p>Stop trusting the convenience method. Write a minimal-POST helper that submits <strong>only</strong> the field that changed, never the whole object — so there&rsquo;s no full blob to re-encode and no encoder to trip over.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#ff79c6">def</span> <span style="color:#50fa7b">safe_set_client_setting</span>(server, client_id, key, value, dry_run<span style="color:#ff79c6">=</span><span style="color:#ff79c6">False</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4"># Snapshot the current settings BEFORE touching anything.</span>
</span></span><span style="display:flex;"><span>    current <span style="color:#ff79c6">=</span> server<span style="color:#ff79c6">.</span>get_client_settings(client_id)
</span></span><span style="display:flex;"><span>    snapshot_path <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">f</span><span style="color:#f1fa8c">&#34;snapshots/</span><span style="color:#f1fa8c">{</span>client_id<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c">-</span><span style="color:#f1fa8c">{</span><span style="color:#8be9fd;font-style:italic">int</span>(time<span style="color:#ff79c6">.</span>time())<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c">.json&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">with</span> <span style="color:#8be9fd;font-style:italic">open</span>(snapshot_path, <span style="color:#f1fa8c">&#34;w&#34;</span>) <span style="color:#ff79c6">as</span> f:
</span></span><span style="display:flex;"><span>        json<span style="color:#ff79c6">.</span>dump(current, f, indent<span style="color:#ff79c6">=</span><span style="color:#bd93f9">2</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> dry_run:
</span></span><span style="display:flex;"><span>        <span style="color:#8be9fd;font-style:italic">print</span>(<span style="color:#f1fa8c">f</span><span style="color:#f1fa8c">&#34;[dry-run] </span><span style="color:#f1fa8c">{</span>client_id<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c">: </span><span style="color:#f1fa8c">{</span>key<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c"> </span><span style="color:#f1fa8c">{</span>current<span style="color:#ff79c6">.</span>get(key)<span style="color:#f1fa8c">!r}</span><span style="color:#f1fa8c"> -&gt; </span><span style="color:#f1fa8c">{</span>value<span style="color:#f1fa8c">!r}</span><span style="color:#f1fa8c">&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4"># Minimal POST: ONLY the one field. No round-trip of the whole blob.</span>
</span></span><span style="display:flex;"><span>    server<span style="color:#ff79c6">.</span>post_setting(client_id, {key: value})
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4"># Read-back validation. Trust nothing.</span>
</span></span><span style="display:flex;"><span>    after <span style="color:#ff79c6">=</span> server<span style="color:#ff79c6">.</span>get_client_settings(client_id)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">if</span> <span style="color:#8be9fd;font-style:italic">str</span>(after<span style="color:#ff79c6">.</span>get(key)) <span style="color:#ff79c6">!=</span> <span style="color:#8be9fd;font-style:italic">str</span>(value):
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">raise</span> RuntimeError(
</span></span><span style="display:flex;"><span>            <span style="color:#f1fa8c">f</span><span style="color:#f1fa8c">&#34;VALIDATION FAILED </span><span style="color:#f1fa8c">{</span>client_id<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c">: </span><span style="color:#f1fa8c">{</span>key<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c"> is </span><span style="color:#f1fa8c">{</span>after<span style="color:#ff79c6">.</span>get(key)<span style="color:#f1fa8c">!r}</span><span style="color:#f1fa8c">, &#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f1fa8c">f</span><span style="color:#f1fa8c">&#34;expected </span><span style="color:#f1fa8c">{</span>value<span style="color:#f1fa8c">!r}</span><span style="color:#f1fa8c"> — restore from </span><span style="color:#f1fa8c">{</span>snapshot_path<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">print</span>(<span style="color:#f1fa8c">f</span><span style="color:#f1fa8c">&#34;✓ </span><span style="color:#f1fa8c">{</span>client_id<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c">: </span><span style="color:#f1fa8c">{</span>key<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c"> = </span><span style="color:#f1fa8c">{</span>value<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c">  (snapshot </span><span style="color:#f1fa8c">{</span>snapshot_path<span style="color:#f1fa8c">}</span><span style="color:#f1fa8c">)&#34;</span>)
</span></span></code></pre></div><p>Recovery itself was unglamorous — restore each client from the JSON snapshot I now wish I&rsquo;d had <em>before</em> the first run:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#ff79c6">for</span> f in snapshots/*-pre-incident.json; <span style="color:#ff79c6">do</span>
</span></span><span style="display:flex;"><span>  <span style="color:#8be9fd;font-style:italic">id</span><span style="color:#ff79c6">=</span><span style="color:#ff79c6">$(</span>basename <span style="color:#f1fa8c">&#34;</span><span style="color:#8be9fd;font-style:italic">$f</span><span style="color:#f1fa8c">&#34;</span> | cut -d- -f1<span style="color:#ff79c6">)</span>
</span></span><span style="display:flex;"><span>  python restore_settings.py --client <span style="color:#f1fa8c">&#34;</span><span style="color:#8be9fd;font-style:italic">$id</span><span style="color:#f1fa8c">&#34;</span> --from <span style="color:#f1fa8c">&#34;</span><span style="color:#8be9fd;font-style:italic">$f</span><span style="color:#f1fa8c">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">done</span>
</span></span></code></pre></div><p>Four guardrails came out of this, and they&rsquo;re now non-negotiable on that box:</p>
<figure style="text-align:center;margin:34px 0">
<svg viewBox="0 0 560 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Safe write pipeline">
  <rect x="0" y="0" width="560" height="220" fill="#11111b" rx="10"/>
  <text x="280" y="30" fill="#cdd6f4" font-family="monospace" font-size="15" text-anchor="middle">safe_set_client_setting()</text>
  <rect x="20" y="55" width="120" height="56" rx="8" fill="#1e1e2e" stroke="#45475a"/>
  <text x="80" y="80" fill="#fab387" font-family="monospace" font-size="12" text-anchor="middle">snapshot</text>
  <text x="80" y="98" fill="#6c7086" font-family="monospace" font-size="10" text-anchor="middle">save .json</text>
  <rect x="160" y="55" width="120" height="56" rx="8" fill="#1e1e2e" stroke="#45475a"/>
  <text x="220" y="80" fill="#cba6f7" font-family="monospace" font-size="12" text-anchor="middle">dry-run?</text>
  <text x="220" y="98" fill="#6c7086" font-family="monospace" font-size="10" text-anchor="middle">preview diff</text>
  <rect x="300" y="55" width="120" height="56" rx="8" fill="#1e1e2e" stroke="#89b4fa"/>
  <text x="360" y="80" fill="#89b4fa" font-family="monospace" font-size="12" text-anchor="middle">minimal POST</text>
  <text x="360" y="98" fill="#6c7086" font-family="monospace" font-size="10" text-anchor="middle">one field only</text>
  <rect x="440" y="55" width="100" height="56" rx="8" fill="#1e1e2e" stroke="#a6e3a1"/>
  <text x="490" y="80" fill="#a6e3a1" font-family="monospace" font-size="12" text-anchor="middle">read-back</text>
  <text x="490" y="98" fill="#6c7086" font-family="monospace" font-size="10" text-anchor="middle">verify ✓</text>
  <line x1="140" y1="83" x2="158" y2="83" stroke="#6c7086" stroke-width="2"/>
  <line x1="280" y1="83" x2="298" y2="83" stroke="#6c7086" stroke-width="2"/>
  <line x1="420" y1="83" x2="438" y2="83" stroke="#6c7086" stroke-width="2"/>
<p><text x="280" y="160" fill="#f38ba8" font-family="monospace" font-size="13" text-anchor="middle">old path: GET whole blob → re-encode → POST whole blob</text>
<text x="280" y="185" fill="#94e2d5" font-family="monospace" font-size="13" text-anchor="middle">new path: never hand the encoder the full object</text>
</svg></p>
<figcaption style="color:#6c7086;font-size:14px;margin-top:8px">Snapshot → dry-run → minimal write → read-back. Every mutation, every time.</figcaption>
</figure>
<h2 id="why-it-happened">Why it happened</h2>
<p>The method name lied — or rather, I read a name and assumed a behavior. &ldquo;Change one client setting&rdquo; sounded surgical. Under the hood it was a full read-modify-write of a complex object, handed to a serializer nobody had stress-tested across repeated calls.</p>
<p>A buggy encoder on a single-field write is annoying. A buggy encoder on a <em>whole-object round-trip</em> is a data-integrity bomb, because every write re-touches every field. The bug didn&rsquo;t need to corrupt the value I changed. It corrupted the structure wrapping everything.</p>
<p>And I had no snapshot, no read-back, and no dry-run — so the first time I learned any of this was when the dashboard went blank.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>Read what the API actually sends, not what the method is named.</strong> &ldquo;Set one field&rdquo; frequently means &ldquo;GET, mutate, re-serialize, POST everything.&rdquo; That round-trip is your real blast radius.</li>
<li><strong>Prefer minimal, targeted writes.</strong> If you can POST just the changed key, do it — you never hand a fragile encoder the whole object to mangle.</li>
<li><strong>Validate by reading back.</strong> A write that &ldquo;succeeded&rdquo; tells you the server accepted bytes, not that the right value landed. Read it again and compare.</li>
<li><strong>Snapshot before you mutate.</strong> A pre-write JSON dump turns a catastrophe into a one-line restore. It costs milliseconds and buys you the whole night back.</li>
<li><strong>Ship a <code>--dry-run</code> for anything that touches production config.</strong> Seeing the diff before it&rsquo;s real would have caught the nesting on call number one.</li>
</ul>
]]></content:encoded></item></channel></rss>