Preventing Errors in Workflow Automation: A Checklist for Scheduled Jobs
Updated
Contents
Most errors in scheduled automation are quiet: the run never started, ran twice, overlapped with itself, hung, or "succeeded" without doing its work. You can prevent them with eight checks: fail with a non-zero exit code, make every run safe to repeat, stop overlapping runs, put a time limit on each run, retry only failures that are safe to retry, check the result instead of trusting the exit code, alert when a run doesn't arrive on schedule, and test that alerts reach someone who will act.
This checklist is for automation that runs on a timer: cron jobs, Kubernetes CronJobs, Hangfire and Quartz.NET recurring jobs, Celery beat, and Laravel's scheduler. Workflow tools fail the same ways, even when their settings have different names.
1. Fail Loudly#
A job that catches every exception and exits 0 hides each failure from the scheduler, its logs, and any monitor. Let failures surface as a non-zero exit code or an uncaught exception.
In shell scripts, start with:
#!/usr/bin/env bash
set -euo pipefail-e exits when a command fails, -u treats an unset variable as an error instead of an empty string, and -o pipefail fails a pipeline when any part of it fails, not only the last command. Without pipefail, pg_dump mydb | gzip > backup.sql.gz exits 0 when pg_dump fails, because gzip succeeded.
Shell script best practices for cron covers where set -e quietly stops applying, which shell /bin/sh really is on each distribution, and the traps and logging every run needs.
2. Make Every Run Safe to Repeat#
Schedulers retry, people rerun jobs by hand, and a deploy can briefly run two schedulers. Write each run so doing it twice has the same effect as doing it once:
- Write with upserts or unique keys instead of blind inserts.
- Process everything since the last successful run, from a recorded watermark, rather than "the last 24 hours".
- Write output to a temporary name and rename it into place when it is complete.
- Give external side effects, such as emails and payments, an idempotency key the receiving system checks.
Idempotent cron jobs has working code for each of these, plus claiming each period before the work starts and a test that runs the job twice.
3. Prevent Overlapping Runs#
When a run takes longer than its interval, the next one starts on top of it. Two copies of an import can double-count, and two cleanups can delete each other's work.
- Linux cron: wrap the command in
flock -n, which exits with status 1 instead of waiting when another run holds the lock:*/10 * * * * flock -n /tmp/sync.lock /opt/app/sync.sh. See flock(1). - Kubernetes: set
concurrencyPolicy: Forbidon the CronJob, and Kubernetes won't start a new Job while the previous one is still running. The skipped run isn't always dropped, though: in the Kubernetes v1.37.1 controller, withoutstartingDeadlineSecondsthe most recent skipped run starts as soon as the previous Job finishes (utils.go), and a deadline well under the interval makes it a true skip. See CronJob concurrency policy. - Hangfire:
[DisableConcurrentExecution]takes a distributed lock around the job. Hangfire's documentation says it reduces overlap rather than ruling it out, so keep the job safe to repeat as well.
A skipped run is worth knowing about, because it means the job is outgrowing its interval. The skip itself sends no check-in, so a monitor sees the overrun behind it instead: in CronRadar, a run that reported start and is still going when the next run's due time plus the grace period passes turns the monitor critical as stuck. Preventing overlapping cron jobs covers choosing between skipping, waiting, and replacing, and why the lock goes outside the check-in wrapper; for a job on several servers, distributed cron compares the ways to run each occurrence once.
4. Put a Time Limit on Every Run#
A run that hangs on a network call never fails, and while it holds the lock, every later run is skipped. Give each run a deadline:
- Shell:
timeout 30m /opt/app/sync.shstops the command after 30 minutes and exits with status 124.timeout -k 1m 30malso sendsSIGKILLif the command is still running a minute later. See timeout(1). - Kubernetes: set
activeDeadlineSecondsin the CronJob's job template. When the Job reaches it, Kubernetes terminates its pods and marks the Job failed with reasonDeadlineExceeded, even if retries remain. See Job termination and cleanup. - In code: set timeouts on HTTP clients, database commands, and lock waits. Some defaults wait forever; Python's
requestslibrary, for one, has no timeout unless you pass one.
Cron job hanging? covers finding what a stuck run is waiting on, making the limit reach every process the job started, and getting alerted when a run hangs.
5. Retry Only What Is Safe to Retry#
Retries fix transient failures, such as a dropped connection, a 503, or a lock timeout. They make permanent ones worse: retrying a validation error or a revoked credential five times delays the alert and repeats any side effect that already happened.
- Retry a small, fixed number of times with increasing delays.
- Don't retry errors that will fail the same way again: 4xx responses other than 429, schema and validation errors, missing permissions.
- Only retry steps that are safe to repeat (check 2).
- Report the final failure. A job that gives up quietly after its retries looks the same as one that never ran.
How to retry failed cron jobs has a POSIX sh retry wrapper with backoff and jitter, the retry settings of common schedulers, and the ways retries hide a failure from whoever is watching.
6. Check the Result, Not Just the Exit Code#
Some of the worst failures exit 0. An import reads an empty file because the upstream export was late. A report query returns no rows because a filter is wrong. A backup finishes in two seconds because it backed up the wrong directory. Each run "succeeds".
Decide what a good run produces and check it before reporting success: at least one record processed, an output file above a minimum size, zero rejected rows. Fail the run when the check fails. Cron job succeeded but did nothing catalogs where these runs come from, with shell and Python checks and thresholds that catch breakage without firing on slow days.
CronRadar can hold that check for you. Configure up to five expectations on a job, such as records_processed >= 1 or errors = 0, then report the values when the run completes:
curl --fail-with-body --silent --show-error --max-time 5 --request POST \
--user "$CRONRADAR_API_KEY:" \
--header "Content-Type: application/json" \
--data '{"outcomes":{"records_processed":842,"errors":0}}' \
"https://cron.life/api/v1/ping/nightly-import/complete" || true|| true keeps a monitoring outage from failing the job under set -e. A completed run that misses or omits a configured expectation is marked outcome-failed and opens an incident showing the observed and expected values. See lifecycle and outcomes.
7. Detect Runs That Never Start#
Every check above runs inside the job. None of them helps when the job doesn't start: the server is down, a deploy removed the crontab, the scheduler process crashed, or someone paused the job and forgot. Nothing fails, so nothing reports a failure.
Catch this from outside the job. Give a monitor the schedule and a grace period, have each run check in, and alert when a check-in doesn't arrive in time. This is a dead man's switch; how to know if a cron job ran compares it with log-based checks. Choosing a grace period sizes that window from measured run times and start delays, and missed runs after downtime covers which schedulers make up a run missed while the server was down.
With CronRadar, the first check-in can carry the cron expression and grace period, and the monitor creates itself from them. For Hangfire and Quartz.NET, the CronRadar extensions find recurring jobs when the scheduler starts, though Quartz 0.1.0 monitors only jobs on repeating simple triggers; see the Hangfire and Quartz.NET guides. NuGet's current CronRadar.Hangfire, 0.1.0 (checked October 25, 2026), can report a failure before Hangfire finishes retrying, including cancellations; reporting only after retries are exhausted requires the pending 0.2.1 release.
8. Send Alerts Where Someone Will Act#
An alert in an unread inbox is just a log entry. Send each job's alerts to the people who own it, through a channel they watch: a team Slack channel, a shared email address, or a webhook into the tool where they track work. Then test the route: trigger a harmless failure in a test job and confirm the right person saw it. How to test a cron job shows how to send a test failure and a test missed run through a separate test application, so no production job has to fail.
A recovery alert matters too. It tells people they can stop investigating.
Checklist#
| Check | Prevents | How |
|---|---|---|
| Non-zero exit on failure | Failures nobody sees | set -euo pipefail; don't swallow exceptions |
| Safe to repeat | Duplicate records and side effects | Upserts, watermarks, idempotency keys |
| No overlapping runs | Double processing | flock -n, concurrencyPolicy: Forbid |
| Time limit | Hung runs that block later runs | timeout, activeDeadlineSeconds, client timeouts |
| Bounded retries | Repeated side effects and late alerts | Retry transient errors only, then report |
| Result check | "Successful" runs that did nothing | Minimum counts and sizes; outcome checks |
| Missed-run detection | Jobs that silently stop | Schedule-aware heartbeat monitoring |
| Tested alert route | Alerts nobody reads | The owner's channel, tested with a harmless failure |
Frequently Asked Questions#
What causes most errors in workflow automation?#
For scheduled automation, the usual causes are changed inputs (a late or empty upstream file), environment differences (credentials, paths, or permissions that differ from where the job was tested), overlapping or hung runs, and jobs that stop starting. The last is the hardest to notice, because it produces no error.
How do I know if a scheduled job failed silently?#
Check what it produced, not just its exit status, and monitor it from outside the job. A schedule-aware monitor alerts when a run is overdue past its grace period, and outcome checks catch runs that finish without doing their work.
Should scheduled jobs retry automatically?#
Yes, for transient errors: a few times, with increasing delays, and only for steps that are safe to repeat. Report the failure once the retries run out.
How do I stop a cron job from running twice at the same time?#
Wrap it in flock -n with a lock file, as in flock -n /tmp/job.lock /opt/app/job.sh. In Kubernetes, set concurrencyPolicy: Forbid on the CronJob.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.