Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 7 min read

My Cron Job Returns 200 When Every Fetch Fails

I wrote a post on my site about monitoring a small product without an ops team. Its strongest claim is that the worst failures are silent: no exception, no 500, just something that should have happened and did not. The l

I wrote a post on my site about monitoring a small product without an ops team. Its strongest claim is that the worst failures are silent: no exception, no 500, just something that should have happened and did not. The layer I told people to build first was "alert on silence".

Then I did what I did with my kill switch post and opened my own repos to see if I follow my own advice. Two products: Upwork Scout, a job-alert SaaS that scans Upwork on a cron and emails matches, and VoiceDash, a white-label portal for Retell voice agents.

The short version: I do not.

Layer one: an error reporter. I have none.

The post says every client needs a crash and error reporter from the first release, tagged with version and platform. I searched both repos for Sentry, Crashlytics, PostHog, Axiom, anything. Zero hits.

What I have instead is console.error. Six calls in Upwork Scout, twenty in VoiceDash. They go to Vercel's function logs, which I read when something already feels wrong. That is not monitoring. That is a diary.

I am not going to pretend this is a hard problem. It is an afternoon. The more interesting findings were in the layers below, because those are the ones where adding Sentry would not have helped at all.

The cron job that succeeds at nothing

Upwork Scout's whole product is one route: /api/cron/scan. Something pings it every 15 minutes, it fetches new jobs per category, scores them against each user's profile with Claude, and emails the good ones.

Here is how it fetches:

const results = await Promise.allSettled(
  activeCats.map((c) => fetchUpworkJobs(c.query, PER_RUN_LIMIT)),
);
results.forEach((r, i) => {
  if (r.status === "fulfilled") {
    billed += r.value.billedItems;
    fetched.push({ cat: activeCats[i].id, jobs: r.value.jobs });
  } else {
    console.error(`fetch failed for ${activeCats[i].id}:`, r.reason);
  }
});

Promise.allSettled is the right call here. One broken category should not kill the run for everyone else. The problem is what happens when every category fails. Say the scraping token expires. fetchUpworkJobs throws apify ...: 401 for every category, each one gets a console.error, and the run carries on with zero jobs. Zero jobs means zero matches, zero emails, and then:

await metaCol().doc("scan").set({ lastRunAt: now, lastStats: stats }, { merge: true });
return NextResponse.json({ ok: true, ...stats });

ok: true, HTTP 200. The pinger sees a 200 and is happy. If it is the GitHub Actions workflow in the repo, that workflow only fails when the status is not 200, so the run goes green. Every 15 minutes, forever, a green check on a product that has stopped doing its one job.

The post calls this a wrong-but-working failure. I built one and gave it a checkmark.

There is a smaller version of the same thing in the AI scorer. If the Claude call fails, scoreJobFit logs and returns null, and the route treats a missing verdict as "filters only":

// Gate on threshold only when we actually have a verdict; AI failure => filters-only.
if (verdict && verdict.score < matching.threshold) {

That is a deliberate degradation and I still think it is the right one: a user would rather get an unscored alert than none. But the post says to log the decision, not just the outcome, so a correct fallback does not look like a bug. The route counts aiScored but not "AI failed, sent anyway". If the model call broke for a day, the stats would show fewer scores and more alerts, and nothing would say why.

The heartbeat nobody reads

That meta/scan write is the part that made me laugh. Every successful run stores lastRunAt and the run's stats in Firestore. That is a heartbeat. It is exactly what the post tells you to build.

I grepped for anything that reads it. Nothing. No admin page, no check, no script. It is a heartbeat written into a drawer.

And the run that never starts writes nothing at all. If the pinger stops, there is no error anywhere, because no code ran. That is the case the heartbeat exists for, and it only works if something outside the job looks at it.

It gets slightly worse. I tried to answer "which pinger is actually live?" from the repo and could not. The README says the GitHub Actions workflow "ships disabled" and recommends cron-job.org. The workflow file in the same repo has its schedule: block active, every 15 minutes plus a daily digest run. One of those two is out of date, and the thing that is supposed to call my product every 15 minutes is the thing I cannot describe with confidence. The only proof it runs is that users keep getting emails.

VoiceDash: the webhook that forgets on purpose

VoiceDash receives Retell's call webhooks, stores the call, then does two follow-ups. If the agent has AI analysis turned on, it asks its own analyze endpoint for a summary. If the agency set forwarding URLs, it passes the raw event on to them, usually an n8n or GoHighLevel workflow:

fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/conversations/analyze`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ callId: call.call_id, agentId: agent.id }),
}).catch(() => {});

for (const fUrl of forwardUrls) {
  fetch(fUrl, { method: "POST", /* ... */ body: JSON.stringify(body) }).catch(() => {});
}

return NextResponse.json({ success: true });

.catch(() => {}) twice. If the analyze call fails, the call sits in the portal with no summary. If the client's n8n URL starts returning 500s, or someone typos it in settings, every event is dropped and the webhook still answers Retell with success: true.

Neither fetch is awaited either. I have not tested whether these reliably finish after the response goes out on Vercel, so I will not claim they get cut off. But "fire, don't wait, ignore the result" is three decisions that each remove a chance of finding out.

The forwarding one is the expensive one. In my post I wrote that when calls and CRM notes drift apart, the post-call automation has broken somewhere in the middle, and that this is the most common failure I see. Here is the code that would cause that drift, and it is mine.

What I would add, in the order the post says

None of this is shipped. These are sketches of what I would do this week, following my own checklist.

1. Make "did nothing" a failure. In the scan route, if every category fetch failed, that is not an ok run:

// sketch, not shipped
const fetchFailures = results.filter((r) => r.status === "rejected").length;
stats.fetchFailures = fetchFailures;
if (activeCats.length > 0 && fetchFailures === activeCats.length) {
  return NextResponse.json({ ok: false, ...stats }, { status: 502 });
}

Now the pinger goes red. Same idea for the scorer: a counter for "AI failed, sent on filters" so the fallback is visible in the stats.

2. Read the heartbeat from outside. The job cannot report its own absence. Either a heartbeat service the route pings on success, or a tiny check on a different schedule that reads meta/scan.lastRunAt and complains if it is older than 45 minutes. The data already exists. It just needs a reader.

3. A downstream arrival check for VoiceDash. Every call on an agent with summaries enabled should have one within an hour. Count the ones that do not:

// sketch, not shipped
const missing = await prisma.conversation.count({
  where: {
    summary: null,
    endedAt: { lt: new Date(Date.now() - 60 * 60 * 1000) },
    agent: { aiAnalysisConfig: { path: ["summary"], equals: true } },
  },
});

For forwarding, the cheapest fix is to stop discarding the result: record the last status code and time per forwarding URL on the agent, and show it in the portal. The agency sees "last delivery failed 2 hours ago, 500" before their client sees a missing lead.

4. Then the error reporter. It is last on my list, not first, which is the opposite of what my post says. I think the post is right for a new build. For an existing product, the silent paths are where I already know the holes are, and an error reporter would not catch any of the three above, because none of them throw.

The honest takeaway

Writing the advice was easy because I have seen these failures on other people's projects. Following it was harder because on my own products each shortcut made sense in isolation. allSettled was correct. The filters-only fallback was correct. Not blocking Retell's webhook on a slow n8n URL was correct. Each one was a sensible way to keep working when something broke. Together they mean that when something breaks, everything keeps reporting success.

That is the pattern I keep finding when I audit my own code against my own posts, the same thing I hit with job pages that rendered empty: graceful degradation without a signal is just a quieter outage.

The original, less self-incriminating version of the checklist is on my site: error monitoring and alerting for a small product.

What is your cheapest "it ran but did nothing" check? I am curious whether people put it in the job itself or always outside it.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.