My Scheduled Agent Ran 40 Times and Did Nothing — Here's the Assertion That Fixed It
I found this one by accident, which is the only way you find it.
Every night at 02:30, a job pulls new filings from an API, normalizes them, and drops them in a queue that feeds my morning briefing. It had been running for about six weeks. Every run: exit code 0. No errors in the log. The scheduler dashboard showed forty consecutive green runs. If you'd asked me, I'd have said that job was my most reliable piece of automation.
Then I noticed my briefing had gotten thin. Not empty — thin. Some days two items, some days none, and I'd been assuming "slow news." I finally got suspicious, went to the log, and realized I had absolutely nothing to be suspicious with: the log said fetched items, wrote to queue on every run, and that's it. No counts. No IDs. Just a sentence I'd written myself, once, and never verified.
So I added one line — a count — and reran it manually.
Zero. Every night. Forty times.
The HTTP call returned 200 with an empty array, because a parameter name in the query had been renamed on their side (since → from_date) and the API's response to an unknown parameter was to cheerfully ignore it. My for loop iterated over an empty list and completed successfully, which is exactly what a for loop over an empty list does. Nothing raised. Nothing retried. Nothing was logged as a problem, because as far as every layer of my stack was concerned, nothing was a problem.
That's the failure mode I want to write about, because I'd covered the other ones: I have a circuit breaker for hard failures, a health monitor for agents that are dying, and dedup for jobs that fire twice. All three of those watch for something happening. None of them watch for something not happening. And "not happening" doesn't throw.
Errors page you. Silence doesn't.
The reason this took six weeks is structural, not lazy. An exception has a moment: it fires, a handler catches it, an alert goes out. A silent no-op has no moment. It has a shape over time, and only if you're graphing the right thing.
My monitoring was graphing the wrong thing. I was tracking run status (success/failed) and run duration. Both were perfect. What I wasn't tracking was the only number that mattered: effects produced. Runs completed. Effects were zero. I had a dashboard that was green because it was measuring the system's opinion of itself.
The fix isn't more logging. It's a different question: not "did it run?" but "did it change anything?"
Rule one: assert the effect, not the attempt
Every job I write now has to declare what a successful run looks like in terms of side effects — rows written, messages sent, files created, IDs emitted. Then the job asserts it before it can report success.
def assert_effect(name, produced, expected=">=1"):
if expected == ">=1" and produced < 1:
raise JobProducedNothingError(
f"{name}: ran fine, produced {produced} effects"
)
log.info("%s: effect ok (%s)", name, produced)
The point isn't the helper. The point is that a job can now fail for the crime of doing nothing. Before, "did nothing" and "did everything" were the same outcome — success — which means my success signal was measuring the wrong universe.
Two things I had to get right, and got wrong first:
An assertion that can't fail isn't an assertion. My first pass was assert len(rows) >= 0. That's a tautology wearing a safety vest. It passed on the empty list and I felt protected for another two days. The assertion has to be able to actually fail on the thing you're worried about — which sounds obvious until you write one at 1 AM.
Assert on the effect, not the attempt. resp.status_code == 200 is an attempt check. len(written_ids) > 0 is an effect check. The whole incident was a case where every attempt check passed and the effect was zero. If your assertion can be satisfied by a system that is doing nothing, it's not an assertion, it's decoration.
Rule two: expected-empty is not unexpected-empty
Here's where I nearly overcorrected, and it's the subtlest part.
Some of my jobs legitimately do nothing most of the time. A watcher that looks for new items in a feed should find zero items on a quiet day. If I make "zero effects" a hard failure everywhere, those jobs scream every night and I'm back to a muted alert channel — which I already learned is worse than no alerts.
So the assertion can't be "produced > 0". It has to be "the source was successfully queried and the emptiness is trustworthy." That's a different check: the request succeeded, returned the expected shape, and the emptiness was an explicit, well-formed empty rather than a default. In my case the giveaway was available the whole time: the response had no pagination envelope. A real empty result would have come back with a cursor and a total. A silently-ignored-parameter empty came back as a bare [].
Practical form: for jobs that may legitimately produce nothing, assert on the envelope, not the count.
data = api.get("/filings", params={"from_date": since})
if not isinstance(data, dict) or "items" not in data:
raise UnexpectedShapeError(f"got {type(data)}: {str(data)[:80]}")
items = data["items"] # zero here is fine — we proved the query was honored
Now "quiet day" and "the API stopped honoring my request" look different, which is the entire job.
Rule three: three states, not two
This is what finally made it usable for both kinds of jobs. Every run now ends in one of three states, and they are not the same thing:
-
succeeded_with_effect— it did the thing, with a count attached. -
no_work_needed— it did nothing and proved that nothing was the correct answer (envelope check above passed). -
failed— includingJobProducedNothingError, i.e. it ran, and the absence of effect was not explained.
Only the third one alerts. The second one is a normal, boring, expected outcome that shows up in the briefing as a single quiet line. Before this split, "did nothing" was indistinguishable from "worked" — and the alerting I did have was tuned to a world where those were the same.
The part that actually caught things
Beyond the original bug, the effect counters caught two more silent failures in the next month: a token that expired and got silently replaced with an anonymous, lower-privilege session (200s, empty results), and a path change that made a downstream job read an empty directory and write a valid, empty file. Neither threw. Both were invisible to status-based monitoring. Both showed up instantly as a flatline in effects.
The dashboard line I now look at first isn't uptime or error rate. It's effects per run, per job. A job that runs and produces nothing, forever, is not a healthy job — it's a green light with the bulb unscrewed.
The transferable bit
If you take one thing: "no error" is not "worked." Your scheduler, your orchestrator, and your CI will happily report success for a process that queried nothing, wrote nothing, and sent nothing, because that is exactly what they were asked to measure.
Whatever your stack is — a nightly ETL, a webhook consumer, a CI step, an agent that posts things — pick one number per unit of work that would be zero if the work silently stopped. Assert on it. Track it over time. And make sure that some of your alerts can fire on a flatline, not just a spike. Spikes are loud and self-reporting. Flatlines need someone to be watching for them on purpose.
Curious how other people handle this: do you assert on effect counts in production jobs, or do you rely on downstream freshness checks (data arriving late → alert)? Both seem to have holes. Drop a comment with what's caught silent failures for you.
Part of my Building in Public series — previously: the guardrail stack I built before going live, timeout means no on approval gates, a circuit breaker that caught three outages, and a health monitor that tells me when my agents are dying.
Top comments (5)
"Errors page you. Silence doesn't" is the line I'd put on the wall. The structural point â an exception has a moment, a no-op only has a shape over time â is why status+duration dashboards lie to you so confidently. We run a fleet of scheduled agents and learned the same lesson the same way: a run that exits 0 in two minutes and a run that exits 0 after doing three hours of real work look identical to the scheduler, so we started asserting on duration bands and output cardinality, not just exit code. A too-fast success is now treated as suspicious by default. The genuinely nasty part of your case is that the API returned 200 to an unknown parameter instead of 400 â Postel's-law politeness turning into a silent data outage. That's an argument for a canary assertion that doesn't trust the happy path at all: assert
count > 0(or count within an expected range) as a hard failure, since "plausibly thin" is indistinguishable from "broken" until you demand a number. Curious whether you alert on absolute zero or on a rolling-baseline drop?The envelope check proves the response has the right shape. It doesn't prove the API honoured your filter, and the filter was the actual failure. An API that ignores an unknown parameter can just as easily return a well-formed envelope, cursor and total included, holding unfiltered results. Yours happened to answer with a bare list, so the shape check would have caught this incident. The next one may answer politely.
Two cheap checks close that gap. First, check the filter against what comes back: if you asked for
from_date=X, every item should be dated on or after X. An ignored filter fails that on the first run that returns anything. Second, for the empty case, keep one query whose answer you already know, such as a past window that returned twelve filings, and run it alongside the real one. If the known window comes back empty, the emptiness in the real window isn't trustworthy either.On your question, I'd run both, because they fail in different places. Effect counts catch a job that stopped producing. Freshness catches a job that keeps producing old data on schedule, which is exactly what an ignored date filter looks like when it returns everything instead of nothing. A count would call that run healthy.
"Measuring the system's opinion of itself" is the part I'd underline. We had nearly the same shape last month with SEC filings. Their directory index quietly stopped listing a filing's documents, the request still came back 200, and our worker kept writing every 8-K as a header with no body, three days in a row, including a big earnings night. Nothing failed, the rows just got thinner. The fix read the document list from a different page of the filing, and every row that ends up with no document now bumps its own counter, so a weekly check can see the rate jump from zero. Your envelope check is the better version though. How do you decide which jobs get it?
This is exactly the kind of issue I've dealt with! I run several cron-based agents for API monitoring and content distribution. The silent failures are the worst - everything looks fine in logs but nothing actually happened. One thing that helped me was adding a 'heartbeat' check: after each run, the agent writes a timestamp to a file, and a separate monitor alerts if the file hasn't been updated within the expected interval. Also, for API-dependent agents, I found that checking both the HTTP status code AND the response body structure catches more edge cases than status codes alone. What monitoring stack are you using for alerting?
the thin briefing you blamed on slow news is the part that got me. you noticed before any dashboard did. curious if you alert on "way fewer than usual" now too or only on zero, since thin was the actual symptom for weeks