A green dashboard and an empty output
On 15 August 2026 someone asked why there had been no social posts for a while. Not an alert, not a monitor, not a log line. A question.
The job in question is ours: a scheduled route that generates three posts each morning. It had run every day, on time, and returned HTTP 200 every single time. The GitHub Action watching it checked the status code, got a 200, and wrote "success" to the log. It did this for roughly two months while producing nothing at all.
The response body, had anyone read it, said this:
{ "success": true, "generated": 0 }
That is the whole failure in one object. success: true and generated: 0 sitting next to each other, contradicting each other, and the monitor only ever looking at the first one.
The shape of code that does this
The route looked roughly like this, and we suspect a large number of readers have something structurally identical:
const generated = [];
for (const topic of topics) {
try {
generated.push(await generatePost(topic));
} catch (err) {
console.error('post failed', err); // swallowed
}
}
return Response.json({ success: true, generated: generated.length });
The per-item try/catch is deliberate and, in isolation, correct. If one topic fails you do not want to lose the other two. That is exactly the behaviour you want in a batch job.
The bug is what happens when every item fails. The loop completes. No exception escapes. The function reaches the last line and reports the only thing it knows how to report: that it finished. Partial failure and total failure produce the same signal, and the total failure is the one you needed to hear about.
The two things that were actually broken
A retired model id. The code called claude-sonnet-4-20250514, hardcoded in four separate call sites. Anthropic retired Claude Sonnet 4 and Opus 4 on the Claude API on 15 June 2026, having notified developers on 14 April. Calls to that id fail. Our job started failing on the retirement date and kept failing, silently, for nine weeks. The notification email presumably arrived. It went to an inbox, not to a monitor, and the code had the string in four places rather than one constant.
Reading the response wrong. The other bug is subtler and worth more attention than the model id. The code pulled text out of the API response as content[0].text. Current Claude models think adaptively, which means the response content array frequently begins with a thinking block rather than a text block. content[0].text is then undefined, and whether that happens depends on whether the model decided to think about that particular prompt.
So before the total failure, we had an intermittent one: roughly one post in three failing, a different one each run, with no pattern. It looked like flakiness in an external API. It was our own index. The correct read is to walk the array and take the blocks whose type is text, never position zero.
The rule
The fix was one line of principle rather than one line of code:
A route that swallows per-item errors must fail the whole run when it produces zero items.
if (generated.length === 0) {
return Response.json(
{ success: false, error: 'produced zero items', failures },
{ status: 500 }
);
}
That is it. Zero items now returns 500, the GitHub Action goes red, and someone finds out on day one instead of day sixty. We also collect the swallowed errors into a failures array and return them, so the alert carries the reason rather than just the fact.
If you want to go one step further, define the expected count. Three posts requested, two produced is a warning worth surfacing. Three requested, zero produced is an outage. Most jobs have a floor below which "it ran" stops meaning anything.
While we were in there: two schedulers, one job
The same investigation turned up something else. vercel.json and a GitHub Actions workflow were both firing the same endpoint at 06:00. The job ran twice every morning and had done for months, which nobody noticed because the output looked normal.
More interestingly, the two schedulers disagreed about when 06:00 was. Vercel kept close to time. GitHub drifted between 40 and 100 minutes. That is not a fault: GitHub documents scheduled workflows as best-effort and delayable under load, and delays at the top of the hour are common enough that the standard advice is to schedule on an odd minute. Vercel is explicit too, and on Hobby accounts crons are limited to once per day and may fire at any point within the specified hour.
The operational point: if your alerting logic assumes a job runs at a precise minute, or your "it hasn't checked in" threshold is tight, a hosted cron scheduler will generate false alarms and train you to ignore them. Set the threshold against the platform's real behaviour, not the cron expression.
The same failure mode on no-code platforms
This is not a bespoke-code problem. Every automation tool has a setting that produces exactly the outcome we had.
| Platform | The setting that hides it | What to do instead |
|---|---|---|
| n8n | Node On Error set to "Continue", so the run completes green with empty items | Add an IF node checking item count and a Stop and Error node; set a workflow-level Error Workflow |
| Zapier | Custom error handler paths report "Handled error", and the Zap is not turned off | Add a filter or path that alerts explicitly when the expected record count is zero |
| Make | "Ignore" error handler on a route | Use a "Break" or explicit rollback so the scenario logs a genuine failure |
| Any HTTP-triggered job | Monitor checks status code only | Check the response body, or ping a heartbeat service only on a successful non-empty run |
Heartbeat or dead man's switch monitoring is the cheapest structural improvement available here. A service such as Healthchecks.io expects a ping at a known interval and alerts when it does not arrive. The critical detail is where you put the ping: inside the success branch, after the count check. Ping unconditionally and you have rebuilt the original bug with a nicer dashboard.
What to check this week
Three things, in order, on every scheduled job you own:
- Find the swallowed errors. Search your workflows for continue-on-error settings and your code for
catchblocks that only log. For each one, ask what the run reports when every item fails. - Add a zero-output failure. Whatever "produced nothing" looks like in your stack, make it non-200, red, and alerting. This is usually under ten lines.
- Check what your monitor actually reads. If it reads a status code and nothing else, it is measuring whether your server is awake, not whether your automation works.
And audit your model ids. If you have a provider model string appearing in more than one file, it will outlive its own support window. One constant, one place, and a note of the retirement dates your vendor publishes.
We publish this because the failure was ours and because the fix generalises. If you would like someone to go through your scheduled jobs and tell you which ones are quietly reporting success over nothing, that is a normal piece of workflow automation work for us, and it is the first thing we look at when we inherit an AI agent someone else built. Examples of what we have shipped are on our case studies page, and you can get in touch if you want your own jobs checked.
Need help with this?
Bloodstone Projects helps businesses implement the strategies covered in this article. Talk to us about Workflow Automation.
Get in touch