Send an Alert When an Excel Report Job Fails
A scheduled report that fails quietly is worse than one that never existed: people keep opening last week's file, assuming it is current. Logging the error is not enough if nobody reads the log. This guide, part of Error Handling and Logging in Excel Automation, wraps a report job so any failure becomes an alert, keeps the alerting itself from failing silently, sends one message per incident instead of one per run, and catches the failure no exception handler can see — the job that did not run.
Prerequisites
The core uses only the standard library; the report itself uses pandas:
pip install pandas openpyxl
A stand-in job that fails the way real ones do — its input export has not arrived:
import json
import logging
import socket
import sys
import traceback
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pandas as pd
log = logging.getLogger("weekly-sales")
def build_report():
orders = pd.read_excel("exports/sales_2026-09-22.xlsx") # not delivered yet
orders.groupby("region")["amount"].sum().to_excel("weekly_sales.xlsx")
Step 1 — Turn any exception into an alert and an exit code
Put one handler around the whole job, at the entry point. It catches Exception — not BaseException, so Ctrl+C still stops the script — builds an alert with what the reader needs to decide what to do, and returns an exit code instead of swallowing the failure:
def run_job(name, job, notify):
started = datetime.now(timezone.utc)
try:
job()
except Exception as error:
log.exception("%s failed", name)
notify({
"job": name,
"host": socket.gethostname(),
"started": started.isoformat(timespec="seconds"),
"error": f"{type(error).__name__}: {error}",
"traceback": traceback.format_exc(limit=4),
})
return 1
return 0
sent = []
exit_code = run_job("weekly-sales", build_report, sent.append)
print(exit_code, sent[0]["error"])
# 1 FileNotFoundError: [Errno 2] No such file or directory: 'exports/sales_2026-09-22.xlsx'
In the real script, end with sys.exit(run_job("weekly-sales", build_report, notify)). The non-zero exit code matters as much as the alert: cron's mail, Task Scheduler's Last Run Result and systemd's unit status all use it, and it is how a pipeline of jobs knows to stop. A handler that logs and exits 0 turns every failure into a success on the scheduler's dashboard.
Keep retries inside the job and the alert outside it. When the input file is merely late, Retry a Failed Excel Report Job in Python waits and tries again; the alert fires only once the retries are used up.
Step 2 — Send through channels that cannot hide the failure
An alert that fails to send — the mail server is down, the webhook URL was rotated — must not replace the original error with its own, or silently vanish. Try each channel separately and fall back to standard error, which the scheduler captures in its log:
def format_alert(alert):
return (f"[FAILED] {alert['job']} on {alert['host']} at {alert['started']}\n"
f"{alert['error']}\n\n{alert['traceback']}")
def notify_all(alert, channels):
delivered = 0
for channel in channels:
try:
channel(alert)
delivered += 1
except Exception:
log.exception("alert channel %s failed", channel.__name__)
if delivered == 0:
print(format_alert(alert), file=sys.stderr) # last resort
return delivered
def broken_webhook(alert):
raise ConnectionError("webhook unreachable")
print(notify_all(sent[0], [broken_webhook]), "channels delivered") # 0, and the alert is on stderr
Two channels that work well for reports are email and a chat webhook. Both are a few lines:
import smtplib
import urllib.request
from email.message import EmailMessage
def email_channel(alert, host="smtp.example.com", to="reports-oncall@example.com"):
msg = EmailMessage()
msg["From"], msg["To"] = "report-jobs@example.com", to
msg["Subject"] = f"[FAILED] {alert['job']}: {alert['error'][:80]}"
msg.set_content(format_alert(alert))
with smtplib.SMTP(host, 587, timeout=20) as server:
server.starttls()
server.send_message(msg)
def webhook_channel(alert, url="https://hooks.slack.com/services/…"):
body = json.dumps({"text": format_alert(alert)[:3000]}).encode()
request = urllib.request.Request(url, body, {"Content-Type": "application/json"})
urllib.request.urlopen(request, timeout=20).close()
Put the error in the email subject: people triage from the subject line, and "FAILED weekly-sales: FileNotFoundError" says whether to act before the message is opened. Cap the webhook text, since chat services reject very long messages. Sending mail with credentials is covered in Send an Excel Report with Gmail from Python, and chat posts in Post an Excel Report to a Slack Channel with Python.
Step 3 — One alert per incident, then a reminder and a recovery
An hourly job that breaks on Monday morning sends dozens of identical alerts by Tuesday, and people learn to filter them. Record the failure state in a small file: alert on the first failure, stay quiet on repeats until a reminder interval passes, and announce the recovery:
STATE = Path("alert_state.json")
REMIND_AFTER = timedelta(hours=24)
def decide(ok, now, state_path=STATE):
state = json.loads(state_path.read_text()) if state_path.exists() else {}
action = None
if ok:
if "failing_since" in state:
action = "recovered"
state = {"last_success": now.isoformat()}
elif "failing_since" not in state:
action = "failed"
state.update(failing_since=now.isoformat(), last_alert=now.isoformat(), failures=1)
else:
state["failures"] += 1
if now - datetime.fromisoformat(state["last_alert"]) >= REMIND_AFTER:
action = "still failing"
state["last_alert"] = now.isoformat()
state_path.write_text(json.dumps(state))
return action
start = datetime(2026, 9, 21, 6, tzinfo=timezone.utc)
outcomes = [True, True] + [False] * 30 + [True, True] # one run per hour
for hour, ok in enumerate(outcomes):
action = decide(ok, start + timedelta(hours=hour))
if action:
print(f"{start + timedelta(hours=hour):%a %H:%M} {action}")
# Mon 08:00 failed
# Tue 08:00 still failing
# Tue 14:00 recovered
Thirty failed hourly runs produce three messages: the failure, one reminder a day later, and the recovery. The recovery message matters more than it looks — without it, whoever got the alert has to check by hand whether the problem is still open. Include the failure count and duration in both follow-ups, which the state file already holds.
Step 4 — Alert when the job did not run at all
Nothing in Steps 1–3 fires if the scheduler never started the job, the machine was off, or the process was killed for running out of memory. Those failures are silent from inside. The fix is a heartbeat: every successful run writes its time (the state file above already does), and a separate check — on another schedule, ideally another machine — alerts when that time is too old:
def check_heartbeat(state_path, expected_every, grace, now):
state = json.loads(state_path.read_text()) if state_path.exists() else {}
if "last_success" not in state:
return "no successful run recorded"
age = now - datetime.fromisoformat(state["last_success"])
if age > expected_every + grace:
return f"last success {age} ago"
return None
now = start + timedelta(hours=len(outcomes) + 5) # the job stopped running
print(check_heartbeat(STATE, timedelta(hours=1), timedelta(minutes=30), now))
# last success 6:00:00 ago
A hosted heartbeat monitor does the same job without a second machine: the job requests a unique URL after each success, and the service alerts when the requests stop. Either way, the check lives outside the job it watches.
Step 5 — Test the alert path on purpose
The alert code runs only when something breaks, so it is the least-tested code in the job. Make it fail deliberately, both in a unit test and once on the real machine with the real channels:
def test_failure_sends_alert(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
received = []
assert run_job("weekly-sales", build_report, received.append) == 1
assert received[0]["error"].startswith("FileNotFoundError")
class _MonkeyPatch: # stand-in for pytest's fixture in this demo
def chdir(self, path):
pass
test_failure_sends_alert(Path("."), _MonkeyPatch())
print("alert path tested")
On the server, point the job at a missing input once after deployment and confirm the email arrives, the chat message appears and the scheduler shows a failed run. That single check catches the usual problems: a blocked outbound port, a webhook URL from a test workspace, a sender address the mail server refuses.
Common pitfalls
- Catching errors deep inside the job. A
try/exceptaround each step that logs and continues produces a half-built report and no alert. Let errors reach the top-level handler unless a step can genuinely recover. - Alerting on warnings. If every late file or empty region sends an alert, the real failure arrives in a flood of noise. Alert on failures that need a person; log the rest.
- Secrets in the alert. Tracebacks can include connection strings or file paths with user names. Keep the traceback short and scrub known secrets before sending it to a chat channel.
- Sharing one state file between jobs. Each job needs its own, or one job's recovery clears another's incident.
Conclusion
A reliable failure alert is a top-level wrapper that reports any exception with its context and exits non-zero, channels that fall back to standard error rather than failing silently, a state file that sends one alert per incident plus a reminder and a recovery notice, and a heartbeat check outside the job for runs that never happened. Test the whole path once by breaking the job on purpose.
Frequently asked questions
How do I get notified when a scheduled Python script fails? Wrap the job's entry point in a try/except that catches Exception, sends an alert with the error and traceback through email or a chat webhook, and exits with a non-zero code so the scheduler also records the failure.
How do I stop an hourly job sending an alert every hour while it is broken? Keep a small state file. Alert on the first failure, stay quiet on the following failures until a reminder interval has passed, and send a recovery message on the first success after a failure.
How do I get alerted when a job did not run at all? An exception handler cannot report a job that was never started or was killed. Record the time of every successful run and have a separate check, on another schedule or machine, alert when that time is older than expected.
What should an alert for a failed report contain? The job name, the host, when it started, the exception type and message, the last few lines of the traceback and where the full log is. Enough to know whether to act now, without opening a remote session first.
Related
- Up one level: Error Handling and Logging in Excel Automation — making report jobs dependable.
- Log Python Excel Script Output to a File — the log the alert points to.
- Validate an Excel Report Before Sending It — failing on bad data, not just on exceptions.
- Skip a Report Run When the Input File Has Not Changed — telling a stale input from a failure.
More on logging, testing & packaging
- GuideTesting and Packaging Excel Automation Scripts
- How-toBlock Large or Sensitive Excel Files with a Pre-commit Hook
- How-toBuild a Command-Line Tool for Excel Reports with argparse
- How-toCompare Excel Output to a Golden File in Tests
- How-toCreate Excel Test Fixtures in Memory for pytest
- How-toDiff Excel Files in Git with a textconv Driver