[{"data":1,"prerenderedAt":2569},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails":3,"surround:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails":2535,"ancestors:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails":2543,"children:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails":2548,"related:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails":2549},{"id":4,"title":5,"body":6,"dateModified":2501,"datePublished":2501,"description":2502,"extension":2503,"faq":2504,"keyword":2513,"kind":2514,"meta":2515,"navigation":330,"path":2524,"seo":2525,"slug":2528,"stem":2529,"topics":2530,"type":2533,"__hash__":2534},"docs\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails\u002Findex.md","Send an Alert When an Excel Report Job Fails",{"type":7,"value":8,"toc":2489},"minimark",[9,19,210,215,218,246,249,431,435,446,696,708,716,720,723,1010,1013,1278,1295,1424,1428,1431,1929,1932,2047,2051,2054,2241,2244,2248,2251,2377,2380,2384,2417,2421,2424,2428,2434,2440,2446,2452,2456,2485],[10,11,12,13,18],"p",{},"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 ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","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.",[20,21,29,30,29,34,29,38,29,46,29,56,29,67,29,74,29,79,29,86,29,91,29,95,29,102,29,108,29,111,29,114,29,118,29,122,29,125,29,130,29,137,29,142,29,145,29,148,29,151,29,155,29,158,29,163,29,170,29,176,29,180,29,184,29,188,29,191,29,194,29,200,29,204,29,207],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"4 0 752 160","img",[25,26],"al-flow-t","al-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[31,32,33],"title",{"id":25},"From a failed run to one useful alert",[35,36,37],"desc",{"id":26},"The report job runs inside a wrapper. If it raises, the wrapper collects the error, host, start time and traceback. A state file decides whether this is a new failure, a reminder or a repeat to suppress. The alert goes to every configured channel, with standard error as a last resort, and the process exits with code 1 so the scheduler records the failure too.",[39,40],"rect",{"x":41,"y":42,"width":43,"height":44,"fill":45},"4","0","752","160","#ffffff",[39,47],{"x":48,"y":49,"width":50,"height":51,"rx":52,"fill":53,"stroke":54,"style":55},"20.0","16.0","115.2","128.0","12","#fce9e9","var(--danger,#dc2626)","stroke-width:2px",[57,58,60],"g",{"transform":59},"translate(65.6,46.5) scale(1.000)",[61,62],"path",{"d":63,"fill":64,"stroke":65,"style":66},"M6 6l12 12 M18 6L6 18","none","var(--accent-ink,#be185d)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[68,69,73],"text",{"x":70,"y":71,"style":72},"77.6","93.0","font-size:13.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Job raises",[68,75,78],{"x":70,"y":76,"style":77},"112.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","any exception",[80,81],"line",{"x1":82,"y1":83,"x2":84,"y2":83,"stroke":85,"style":55},"140.2","80.0","162.2","var(--brand,#5b5cf0)",[87,88],"polygon",{"points":89,"fill":90},"166.2,80.0 157.2,85.0 157.2,75.0","#5b5cf0",[39,92],{"x":93,"y":49,"width":50,"height":51,"rx":52,"fill":94,"stroke":85,"style":55},"171.2","#f0f4ff",[57,96,98],{"transform":97},"translate(216.8,29.0) scale(1.000)",[61,99],{"d":100,"fill":64,"stroke":101,"style":66},"M9 8l-4 4 4 4 M15 8l4 4-4 4","var(--brand-strong,#4338ca)",[68,103,107],{"x":104,"y":105,"style":106},"228.8","75.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Wrapper",[68,109,110],{"x":104,"y":71,"style":106},"collects",[68,112,113],{"x":104,"y":76,"style":77},"error, host,",[68,115,117],{"x":104,"y":116,"style":77},"130.0","traceback",[80,119],{"x1":120,"y1":83,"x2":121,"y2":83,"stroke":85,"style":55},"291.4","313.4",[87,123],{"points":124,"fill":90},"317.4,80.0 308.4,85.0 308.4,75.0",[39,126],{"x":127,"y":49,"width":50,"height":51,"rx":52,"fill":128,"stroke":129,"style":55},"322.4","#fdefd8","var(--gold,#b4740a)",[57,131,133],{"transform":132},"translate(368.0,29.0) scale(1.000)",[61,134],{"d":135,"fill":64,"stroke":136,"style":66},"M12 9a3 3 0 1 0 .01 0z M12 2.5v3 M12 18.5v3 M2.5 12h3 M18.5 12h3 M5.3 5.3l2.1 2.1 M16.6 16.6l2.1 2.1 M5.3 18.7l2.1-2.1 M16.6 7.4l2.1-2.1","var(--gold-ink,#7a4e06)",[68,138,141],{"x":139,"y":105,"style":140},"380.0","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","State",[68,143,144],{"x":139,"y":71,"style":140},"decides",[68,146,147],{"x":139,"y":76,"style":77},"new, reminder",[68,149,150],{"x":139,"y":116,"style":77},"or quiet",[80,152],{"x1":153,"y1":83,"x2":154,"y2":83,"stroke":85,"style":55},"442.6","464.6",[87,156],{"points":157,"fill":90},"468.6,80.0 459.6,85.0 459.6,75.0",[39,159],{"x":160,"y":49,"width":50,"height":51,"rx":52,"fill":161,"stroke":162,"style":55},"473.6","#d9f4f1","var(--teal,#0f9488)",[57,164,166],{"transform":165},"translate(519.2,37.8) scale(1.000)",[61,167],{"d":168,"fill":64,"stroke":169,"style":66},"M3 11l18-8-8 18-2-8z","var(--teal-ink,#0b6157)",[68,171,175],{"x":172,"y":173,"style":174},"531.2","84.2","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Channels",[68,177,179],{"x":172,"y":178,"style":77},"103.8","email, webhook,",[68,181,183],{"x":172,"y":182,"style":77},"121.2","stderr",[80,185],{"x1":186,"y1":83,"x2":187,"y2":83,"stroke":85,"style":55},"593.8","615.8",[87,189],{"points":190,"fill":90},"619.8,80.0 610.8,85.0 610.8,75.0",[39,192],{"x":193,"y":49,"width":50,"height":51,"rx":52,"fill":94,"stroke":85,"style":55},"624.8",[57,195,197],{"transform":196},"translate(670.4,37.8) scale(1.000)",[61,198],{"d":199,"fill":64,"stroke":101,"style":66},"M4 5h16v14H4z M7 9l3 3-3 3 M12 15h5",[68,201,203],{"x":202,"y":173,"style":106},"682.4","Exit code 1",[68,205,206],{"x":202,"y":178,"style":77},"scheduler sees",[68,208,209],{"x":202,"y":182,"style":77},"it",[211,212,214],"h2",{"id":213},"prerequisites","Prerequisites",[10,216,217],{},"The core uses only the standard library; the report itself uses pandas:",[219,220,225],"pre",{"className":221,"code":222,"language":223,"meta":224,"style":224},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[226,227,228],"code",{"__ignoreMap":224},[229,230,232,236,240,243],"span",{"class":80,"line":231},1,[229,233,235],{"class":234},"sMTad","pip",[229,237,239],{"class":238},"srMev"," install",[229,241,242],{"class":238}," pandas",[229,244,245],{"class":238}," openpyxl\n",[10,247,248],{},"A stand-in job that fails the way real ones do — its input export has not arrived:",[219,250,254],{"className":251,"code":252,"language":253,"meta":224,"style":224},"language-python shiki shiki-themes github-light github-dark-high-contrast","import json\nimport logging\nimport socket\nimport sys\nimport traceback\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nimport pandas as pd\n\nlog = logging.getLogger(\"weekly-sales\")\n\ndef build_report():\n    orders = pd.read_excel(\"exports\u002Fsales_2026-09-22.xlsx\")      # not delivered yet\n    orders.groupby(\"region\")[\"amount\"].sum().to_excel(\"weekly_sales.xlsx\")\n","python",[226,255,256,266,274,282,290,298,312,325,332,346,351,369,374,387,408],{"__ignoreMap":224},[229,257,258,262],{"class":80,"line":231},[229,259,261],{"class":260},"s-kum","import",[229,263,265],{"class":264},"skGVy"," json\n",[229,267,269,271],{"class":80,"line":268},2,[229,270,261],{"class":260},[229,272,273],{"class":264}," logging\n",[229,275,277,279],{"class":80,"line":276},3,[229,278,261],{"class":260},[229,280,281],{"class":264}," socket\n",[229,283,285,287],{"class":80,"line":284},4,[229,286,261],{"class":260},[229,288,289],{"class":264}," sys\n",[229,291,293,295],{"class":80,"line":292},5,[229,294,261],{"class":260},[229,296,297],{"class":264}," traceback\n",[229,299,301,304,307,309],{"class":80,"line":300},6,[229,302,303],{"class":260},"from",[229,305,306],{"class":264}," datetime ",[229,308,261],{"class":260},[229,310,311],{"class":264}," datetime, timedelta, timezone\n",[229,313,315,317,320,322],{"class":80,"line":314},7,[229,316,303],{"class":260},[229,318,319],{"class":264}," pathlib ",[229,321,261],{"class":260},[229,323,324],{"class":264}," Path\n",[229,326,328],{"class":80,"line":327},8,[229,329,331],{"emptyLinePlaceholder":330},true,"\n",[229,333,335,337,340,343],{"class":80,"line":334},9,[229,336,261],{"class":260},[229,338,339],{"class":264}," pandas ",[229,341,342],{"class":260},"as",[229,344,345],{"class":264}," pd\n",[229,347,349],{"class":80,"line":348},10,[229,350,331],{"emptyLinePlaceholder":330},[229,352,354,357,360,363,366],{"class":80,"line":353},11,[229,355,356],{"class":264},"log ",[229,358,359],{"class":260},"=",[229,361,362],{"class":264}," logging.getLogger(",[229,364,365],{"class":238},"\"weekly-sales\"",[229,367,368],{"class":264},")\n",[229,370,372],{"class":80,"line":371},12,[229,373,331],{"emptyLinePlaceholder":330},[229,375,377,380,384],{"class":80,"line":376},13,[229,378,379],{"class":260},"def",[229,381,383],{"class":382},"s_Opv"," build_report",[229,385,386],{"class":264},"():\n",[229,388,390,393,395,398,401,404],{"class":80,"line":389},14,[229,391,392],{"class":264},"    orders ",[229,394,359],{"class":260},[229,396,397],{"class":264}," pd.read_excel(",[229,399,400],{"class":238},"\"exports\u002Fsales_2026-09-22.xlsx\"",[229,402,403],{"class":264},")      ",[229,405,407],{"class":406},"s-wDw","# not delivered yet\n",[229,409,411,414,417,420,423,426,429],{"class":80,"line":410},15,[229,412,413],{"class":264},"    orders.groupby(",[229,415,416],{"class":238},"\"region\"",[229,418,419],{"class":264},")[",[229,421,422],{"class":238},"\"amount\"",[229,424,425],{"class":264},"].sum().to_excel(",[229,427,428],{"class":238},"\"weekly_sales.xlsx\"",[229,430,368],{"class":264},[211,432,434],{"id":433},"step-1-turn-any-exception-into-an-alert-and-an-exit-code","Step 1 — Turn any exception into an alert and an exit code",[10,436,437,438,441,442,445],{},"Put one handler around the whole job, at the entry point. It catches ",[226,439,440],{},"Exception"," — not ",[226,443,444],{},"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:",[219,447,449],{"className":251,"code":448,"language":253,"meta":224,"style":224},"def run_job(name, job, notify):\n    started = datetime.now(timezone.utc)\n    try:\n        job()\n    except Exception as error:\n        log.exception(\"%s failed\", name)\n        notify({\n            \"job\": name,\n            \"host\": socket.gethostname(),\n            \"started\": started.isoformat(timespec=\"seconds\"),\n            \"error\": f\"{type(error).__name__}: {error}\",\n            \"traceback\": traceback.format_exc(limit=4),\n        })\n        return 1\n    return 0\n\nsent = []\nexit_code = run_job(\"weekly-sales\", build_report, sent.append)\nprint(exit_code, sent[0][\"error\"])\n# 1 FileNotFoundError: [Errno 2] No such file or directory: 'exports\u002Fsales_2026-09-22.xlsx'\n",[226,450,451,461,471,479,484,499,517,522,530,538,558,600,617,622,630,638,643,654,670,690],{"__ignoreMap":224},[229,452,453,455,458],{"class":80,"line":231},[229,454,379],{"class":260},[229,456,457],{"class":382}," run_job",[229,459,460],{"class":264},"(name, job, notify):\n",[229,462,463,466,468],{"class":80,"line":268},[229,464,465],{"class":264},"    started ",[229,467,359],{"class":260},[229,469,470],{"class":264}," datetime.now(timezone.utc)\n",[229,472,473,476],{"class":80,"line":276},[229,474,475],{"class":260},"    try",[229,477,478],{"class":264},":\n",[229,480,481],{"class":80,"line":284},[229,482,483],{"class":264},"        job()\n",[229,485,486,489,493,496],{"class":80,"line":292},[229,487,488],{"class":260},"    except",[229,490,492],{"class":491},"sP0c6"," Exception",[229,494,495],{"class":260}," as",[229,497,498],{"class":264}," error:\n",[229,500,501,504,507,511,514],{"class":80,"line":300},[229,502,503],{"class":264},"        log.exception(",[229,505,506],{"class":238},"\"",[229,508,510],{"class":509},"sSjpA","%s",[229,512,513],{"class":238}," failed\"",[229,515,516],{"class":264},", name)\n",[229,518,519],{"class":80,"line":314},[229,520,521],{"class":264},"        notify({\n",[229,523,524,527],{"class":80,"line":327},[229,525,526],{"class":238},"            \"job\"",[229,528,529],{"class":264},": name,\n",[229,531,532,535],{"class":80,"line":334},[229,533,534],{"class":238},"            \"host\"",[229,536,537],{"class":264},": socket.gethostname(),\n",[229,539,540,543,546,550,552,555],{"class":80,"line":348},[229,541,542],{"class":238},"            \"started\"",[229,544,545],{"class":264},": started.isoformat(",[229,547,549],{"class":548},"sa561","timespec",[229,551,359],{"class":260},[229,553,554],{"class":238},"\"seconds\"",[229,556,557],{"class":264},"),\n",[229,559,560,563,566,569,571,574,577,580,583,586,588,590,593,595,597],{"class":80,"line":353},[229,561,562],{"class":238},"            \"error\"",[229,564,565],{"class":264},": ",[229,567,568],{"class":260},"f",[229,570,506],{"class":238},[229,572,573],{"class":509},"{",[229,575,576],{"class":491},"type",[229,578,579],{"class":264},"(error).",[229,581,582],{"class":491},"__name__",[229,584,585],{"class":509},"}",[229,587,565],{"class":238},[229,589,573],{"class":509},[229,591,592],{"class":264},"error",[229,594,585],{"class":509},[229,596,506],{"class":238},[229,598,599],{"class":264},",\n",[229,601,602,605,608,611,613,615],{"class":80,"line":371},[229,603,604],{"class":238},"            \"traceback\"",[229,606,607],{"class":264},": traceback.format_exc(",[229,609,610],{"class":548},"limit",[229,612,359],{"class":260},[229,614,41],{"class":491},[229,616,557],{"class":264},[229,618,619],{"class":80,"line":376},[229,620,621],{"class":264},"        })\n",[229,623,624,627],{"class":80,"line":389},[229,625,626],{"class":260},"        return",[229,628,629],{"class":491}," 1\n",[229,631,632,635],{"class":80,"line":410},[229,633,634],{"class":260},"    return",[229,636,637],{"class":491}," 0\n",[229,639,641],{"class":80,"line":640},16,[229,642,331],{"emptyLinePlaceholder":330},[229,644,646,649,651],{"class":80,"line":645},17,[229,647,648],{"class":264},"sent ",[229,650,359],{"class":260},[229,652,653],{"class":264}," []\n",[229,655,657,660,662,665,667],{"class":80,"line":656},18,[229,658,659],{"class":264},"exit_code ",[229,661,359],{"class":260},[229,663,664],{"class":264}," run_job(",[229,666,365],{"class":238},[229,668,669],{"class":264},", build_report, sent.append)\n",[229,671,673,676,679,681,684,687],{"class":80,"line":672},19,[229,674,675],{"class":491},"print",[229,677,678],{"class":264},"(exit_code, sent[",[229,680,42],{"class":491},[229,682,683],{"class":264},"][",[229,685,686],{"class":238},"\"error\"",[229,688,689],{"class":264},"])\n",[229,691,693],{"class":80,"line":692},20,[229,694,695],{"class":406},"# 1 FileNotFoundError: [Errno 2] No such file or directory: 'exports\u002Fsales_2026-09-22.xlsx'\n",[10,697,698,699,702,703,707],{},"In the real script, end with ",[226,700,701],{},"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 ",[704,705,706],"em",{},"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.",[10,709,710,711,715],{},"Keep retries inside the job and the alert outside it. When the input file is merely late, ",[14,712,714],{"href":713},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002F","Retry a Failed Excel Report Job in Python"," waits and tries again; the alert fires only once the retries are used up.",[211,717,719],{"id":718},"step-2-send-through-channels-that-cannot-hide-the-failure","Step 2 — Send through channels that cannot hide the failure",[10,721,722],{},"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:",[219,724,726],{"className":251,"code":725,"language":253,"meta":224,"style":224},"def format_alert(alert):\n    return (f\"[FAILED] {alert['job']} on {alert['host']} at {alert['started']}\\n\"\n            f\"{alert['error']}\\n\\n{alert['traceback']}\")\n\ndef notify_all(alert, channels):\n    delivered = 0\n    for channel in channels:\n        try:\n            channel(alert)\n            delivered += 1\n        except Exception:\n            log.exception(\"alert channel %s failed\", channel.__name__)\n    if delivered == 0:\n        print(format_alert(alert), file=sys.stderr)          # last resort\n    return delivered\n\ndef broken_webhook(alert):\n    raise ConnectionError(\"webhook unreachable\")\n\nprint(notify_all(sent[0], [broken_webhook]), \"channels delivered\")   # 0, and the alert is on stderr\n",[226,727,728,738,795,827,831,841,850,864,871,876,886,895,914,930,949,956,960,969,985,989],{"__ignoreMap":224},[229,729,730,732,735],{"class":80,"line":231},[229,731,379],{"class":260},[229,733,734],{"class":382}," format_alert",[229,736,737],{"class":264},"(alert):\n",[229,739,740,742,745,747,750,752,755,758,761,763,766,768,770,773,775,777,780,782,784,787,789,792],{"class":80,"line":268},[229,741,634],{"class":260},[229,743,744],{"class":264}," (",[229,746,568],{"class":260},[229,748,749],{"class":238},"\"[FAILED] ",[229,751,573],{"class":509},[229,753,754],{"class":264},"alert[",[229,756,757],{"class":238},"'job'",[229,759,760],{"class":264},"]",[229,762,585],{"class":509},[229,764,765],{"class":238}," on ",[229,767,573],{"class":509},[229,769,754],{"class":264},[229,771,772],{"class":238},"'host'",[229,774,760],{"class":264},[229,776,585],{"class":509},[229,778,779],{"class":238}," at ",[229,781,573],{"class":509},[229,783,754],{"class":264},[229,785,786],{"class":238},"'started'",[229,788,760],{"class":264},[229,790,791],{"class":509},"}\\n",[229,793,794],{"class":238},"\"\n",[229,796,797,800,802,804,806,809,811,814,816,819,821,823,825],{"class":80,"line":276},[229,798,799],{"class":260},"            f",[229,801,506],{"class":238},[229,803,573],{"class":509},[229,805,754],{"class":264},[229,807,808],{"class":238},"'error'",[229,810,760],{"class":264},[229,812,813],{"class":509},"}\\n\\n{",[229,815,754],{"class":264},[229,817,818],{"class":238},"'traceback'",[229,820,760],{"class":264},[229,822,585],{"class":509},[229,824,506],{"class":238},[229,826,368],{"class":264},[229,828,829],{"class":80,"line":284},[229,830,331],{"emptyLinePlaceholder":330},[229,832,833,835,838],{"class":80,"line":292},[229,834,379],{"class":260},[229,836,837],{"class":382}," notify_all",[229,839,840],{"class":264},"(alert, channels):\n",[229,842,843,846,848],{"class":80,"line":300},[229,844,845],{"class":264},"    delivered ",[229,847,359],{"class":260},[229,849,637],{"class":491},[229,851,852,855,858,861],{"class":80,"line":314},[229,853,854],{"class":260},"    for",[229,856,857],{"class":264}," channel ",[229,859,860],{"class":260},"in",[229,862,863],{"class":264}," channels:\n",[229,865,866,869],{"class":80,"line":327},[229,867,868],{"class":260},"        try",[229,870,478],{"class":264},[229,872,873],{"class":80,"line":334},[229,874,875],{"class":264},"            channel(alert)\n",[229,877,878,881,884],{"class":80,"line":348},[229,879,880],{"class":264},"            delivered ",[229,882,883],{"class":260},"+=",[229,885,629],{"class":491},[229,887,888,891,893],{"class":80,"line":353},[229,889,890],{"class":260},"        except",[229,892,492],{"class":491},[229,894,478],{"class":264},[229,896,897,900,903,905,907,910,912],{"class":80,"line":371},[229,898,899],{"class":264},"            log.exception(",[229,901,902],{"class":238},"\"alert channel ",[229,904,510],{"class":509},[229,906,513],{"class":238},[229,908,909],{"class":264},", channel.",[229,911,582],{"class":491},[229,913,368],{"class":264},[229,915,916,919,922,925,928],{"class":80,"line":376},[229,917,918],{"class":260},"    if",[229,920,921],{"class":264}," delivered ",[229,923,924],{"class":260},"==",[229,926,927],{"class":491}," 0",[229,929,478],{"class":264},[229,931,932,935,938,941,943,946],{"class":80,"line":389},[229,933,934],{"class":491},"        print",[229,936,937],{"class":264},"(format_alert(alert), ",[229,939,940],{"class":548},"file",[229,942,359],{"class":260},[229,944,945],{"class":264},"sys.stderr)          ",[229,947,948],{"class":406},"# last resort\n",[229,950,951,953],{"class":80,"line":410},[229,952,634],{"class":260},[229,954,955],{"class":264}," delivered\n",[229,957,958],{"class":80,"line":640},[229,959,331],{"emptyLinePlaceholder":330},[229,961,962,964,967],{"class":80,"line":645},[229,963,379],{"class":260},[229,965,966],{"class":382}," broken_webhook",[229,968,737],{"class":264},[229,970,971,974,977,980,983],{"class":80,"line":656},[229,972,973],{"class":260},"    raise",[229,975,976],{"class":491}," ConnectionError",[229,978,979],{"class":264},"(",[229,981,982],{"class":238},"\"webhook unreachable\"",[229,984,368],{"class":264},[229,986,987],{"class":80,"line":672},[229,988,331],{"emptyLinePlaceholder":330},[229,990,991,993,996,998,1001,1004,1007],{"class":80,"line":692},[229,992,675],{"class":491},[229,994,995],{"class":264},"(notify_all(sent[",[229,997,42],{"class":491},[229,999,1000],{"class":264},"], [broken_webhook]), ",[229,1002,1003],{"class":238},"\"channels delivered\"",[229,1005,1006],{"class":264},")   ",[229,1008,1009],{"class":406},"# 0, and the alert is on stderr\n",[10,1011,1012],{},"Two channels that work well for reports are email and a chat webhook. Both are a few lines:",[219,1014,1016],{"className":251,"code":1015,"language":253,"meta":224,"style":224},"import smtplib\nimport urllib.request\nfrom email.message import EmailMessage\n\ndef email_channel(alert, host=\"smtp.example.com\", to=\"reports-oncall@example.com\"):\n    msg = EmailMessage()\n    msg[\"From\"], msg[\"To\"] = \"report-jobs@example.com\", to\n    msg[\"Subject\"] = f\"[FAILED] {alert['job']}: {alert['error'][:80]}\"\n    msg.set_content(format_alert(alert))\n    with smtplib.SMTP(host, 587, timeout=20) as server:\n        server.starttls()\n        server.send_message(msg)\n\ndef webhook_channel(alert, url=\"https:\u002F\u002Fhooks.slack.com\u002Fservices\u002F…\"):\n    body = json.dumps({\"text\": format_alert(alert)[:3000]}).encode()\n    request = urllib.request.Request(url, body, {\"Content-Type\": \"application\u002Fjson\"})\n    urllib.request.urlopen(request, timeout=20).close()\n",[226,1017,1018,1025,1032,1044,1048,1074,1084,1109,1155,1160,1190,1195,1200,1204,1221,1243,1264],{"__ignoreMap":224},[229,1019,1020,1022],{"class":80,"line":231},[229,1021,261],{"class":260},[229,1023,1024],{"class":264}," smtplib\n",[229,1026,1027,1029],{"class":80,"line":268},[229,1028,261],{"class":260},[229,1030,1031],{"class":264}," urllib.request\n",[229,1033,1034,1036,1039,1041],{"class":80,"line":276},[229,1035,303],{"class":260},[229,1037,1038],{"class":264}," email.message ",[229,1040,261],{"class":260},[229,1042,1043],{"class":264}," EmailMessage\n",[229,1045,1046],{"class":80,"line":284},[229,1047,331],{"emptyLinePlaceholder":330},[229,1049,1050,1052,1055,1058,1060,1063,1066,1068,1071],{"class":80,"line":292},[229,1051,379],{"class":260},[229,1053,1054],{"class":382}," email_channel",[229,1056,1057],{"class":264},"(alert, host",[229,1059,359],{"class":260},[229,1061,1062],{"class":238},"\"smtp.example.com\"",[229,1064,1065],{"class":264},", to",[229,1067,359],{"class":260},[229,1069,1070],{"class":238},"\"reports-oncall@example.com\"",[229,1072,1073],{"class":264},"):\n",[229,1075,1076,1079,1081],{"class":80,"line":300},[229,1077,1078],{"class":264},"    msg ",[229,1080,359],{"class":260},[229,1082,1083],{"class":264}," EmailMessage()\n",[229,1085,1086,1089,1092,1095,1098,1101,1103,1106],{"class":80,"line":314},[229,1087,1088],{"class":264},"    msg[",[229,1090,1091],{"class":238},"\"From\"",[229,1093,1094],{"class":264},"], msg[",[229,1096,1097],{"class":238},"\"To\"",[229,1099,1100],{"class":264},"] ",[229,1102,359],{"class":260},[229,1104,1105],{"class":238}," \"report-jobs@example.com\"",[229,1107,1108],{"class":264},", to\n",[229,1110,1111,1113,1116,1118,1120,1123,1125,1127,1129,1131,1133,1135,1137,1139,1141,1143,1146,1149,1151,1153],{"class":80,"line":327},[229,1112,1088],{"class":264},[229,1114,1115],{"class":238},"\"Subject\"",[229,1117,1100],{"class":264},[229,1119,359],{"class":260},[229,1121,1122],{"class":260}," f",[229,1124,749],{"class":238},[229,1126,573],{"class":509},[229,1128,754],{"class":264},[229,1130,757],{"class":238},[229,1132,760],{"class":264},[229,1134,585],{"class":509},[229,1136,565],{"class":238},[229,1138,573],{"class":509},[229,1140,754],{"class":264},[229,1142,808],{"class":238},[229,1144,1145],{"class":264},"][:",[229,1147,1148],{"class":491},"80",[229,1150,760],{"class":264},[229,1152,585],{"class":509},[229,1154,794],{"class":238},[229,1156,1157],{"class":80,"line":334},[229,1158,1159],{"class":264},"    msg.set_content(format_alert(alert))\n",[229,1161,1162,1165,1168,1171,1174,1177,1179,1182,1185,1187],{"class":80,"line":348},[229,1163,1164],{"class":260},"    with",[229,1166,1167],{"class":264}," smtplib.SMTP(host, ",[229,1169,1170],{"class":491},"587",[229,1172,1173],{"class":264},", ",[229,1175,1176],{"class":548},"timeout",[229,1178,359],{"class":260},[229,1180,1181],{"class":491},"20",[229,1183,1184],{"class":264},") ",[229,1186,342],{"class":260},[229,1188,1189],{"class":264}," server:\n",[229,1191,1192],{"class":80,"line":353},[229,1193,1194],{"class":264},"        server.starttls()\n",[229,1196,1197],{"class":80,"line":371},[229,1198,1199],{"class":264},"        server.send_message(msg)\n",[229,1201,1202],{"class":80,"line":376},[229,1203,331],{"emptyLinePlaceholder":330},[229,1205,1206,1208,1211,1214,1216,1219],{"class":80,"line":389},[229,1207,379],{"class":260},[229,1209,1210],{"class":382}," webhook_channel",[229,1212,1213],{"class":264},"(alert, url",[229,1215,359],{"class":260},[229,1217,1218],{"class":238},"\"https:\u002F\u002Fhooks.slack.com\u002Fservices\u002F…\"",[229,1220,1073],{"class":264},[229,1222,1223,1226,1228,1231,1234,1237,1240],{"class":80,"line":410},[229,1224,1225],{"class":264},"    body ",[229,1227,359],{"class":260},[229,1229,1230],{"class":264}," json.dumps({",[229,1232,1233],{"class":238},"\"text\"",[229,1235,1236],{"class":264},": format_alert(alert)[:",[229,1238,1239],{"class":491},"3000",[229,1241,1242],{"class":264},"]}).encode()\n",[229,1244,1245,1248,1250,1253,1256,1258,1261],{"class":80,"line":640},[229,1246,1247],{"class":264},"    request ",[229,1249,359],{"class":260},[229,1251,1252],{"class":264}," urllib.request.Request(url, body, {",[229,1254,1255],{"class":238},"\"Content-Type\"",[229,1257,565],{"class":264},[229,1259,1260],{"class":238},"\"application\u002Fjson\"",[229,1262,1263],{"class":264},"})\n",[229,1265,1266,1269,1271,1273,1275],{"class":80,"line":645},[229,1267,1268],{"class":264},"    urllib.request.urlopen(request, ",[229,1270,1176],{"class":548},[229,1272,359],{"class":260},[229,1274,1181],{"class":491},[229,1276,1277],{"class":264},").close()\n",[10,1279,1280,1281,1284,1285,1289,1290,1294],{},"Put the error in the email subject: people triage from the subject line, and \"",[229,1282,1283],{},"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 ",[14,1286,1288],{"href":1287},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-gmail\u002F","Send an Excel Report with Gmail from Python",", and chat posts in ",[14,1291,1293],{"href":1292},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fpost-an-excel-report-to-a-slack-channel-with-python\u002F","Post an Excel Report to a Slack Channel with Python",".",[20,1296,29,1301,29,1304,29,1307,29,1310,29,1314,29,1320,29,1324,29,1328,29,1333,29,1339,29,1345,29,1350,29,1353,29,1356,29,1359,29,1364,29,1369,29,1373,29,1376,29,1379,29,1382,29,1388,29,1392,29,1396,29,1399,29,1402,29,1405,29,1410,29,1414,29,1418,29,1421],{"viewBox":1297,"role":23,"ariaLabelledBy":1298,"xmlns":27,"style":28},"4 0 752 436",[1299,1300],"al-channels-t","al-channels-d",[31,1302,1303],{"id":1299},"One alert, several ways to be seen",[35,1305,1306],{"id":1300},"The alert goes to each configured channel independently. Email carries the full traceback; a chat webhook reaches whoever is watching the channel; the log file keeps the full history. If every channel fails, the alert is written to standard error, which the scheduler records.",[39,1308],{"x":41,"y":42,"width":43,"height":1309,"fill":45},"436",[39,1311],{"x":48,"y":1312,"width":1313,"height":71,"rx":52,"fill":53,"stroke":54,"style":55},"171.5","220.0",[57,1315,1317],{"transform":1316},"translate(118.0,184.5) scale(1.000)",[61,1318],{"d":1319,"fill":64,"stroke":65,"style":66},"M12 3.5l9.5 17h-19z M12 10v5 M12 17.8v.4",[68,1321,1323],{"x":116,"y":1322,"style":72},"231.0","alert",[68,1325,1327],{"x":116,"y":1326,"style":77},"250.5","job, host, error, traceback",[39,1329],{"x":1330,"y":49,"width":1331,"height":1332,"rx":52,"fill":94,"stroke":85,"style":55},"490.0","250.0","92.0",[57,1334,1336],{"transform":1335},"translate(603.0,29.0) scale(1.000)",[61,1337],{"d":1338,"fill":64,"stroke":101,"style":66},"M3 6h18v12H3z M3 7l9 6 9-6",[68,1340,1344],{"x":1341,"y":1342,"style":1343},"615.0","75.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Email",[68,1346,1349],{"x":1341,"y":1347,"style":1348},"94.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","error in the subject",[61,1351],{"d":1352,"fill":64,"stroke":85,"style":55},"M244.0,218.0 C364.0,218.0 364.0,62.0 480.0,62.0",[87,1354],{"points":1355,"fill":90},"484.0,62.0 475.0,67.0 475.0,57.0",[39,1357],{"x":1330,"y":1358,"width":1331,"height":1332,"rx":52,"fill":161,"stroke":162,"style":55},"120.0",[57,1360,1362],{"transform":1361},"translate(603.0,133.0) scale(1.000)",[61,1363],{"d":168,"fill":64,"stroke":169,"style":66},[68,1365,1368],{"x":1341,"y":1366,"style":1367},"179.0","font-size:13px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Chat webhook",[68,1370,1372],{"x":1341,"y":1371,"style":1348},"198.0","capped text",[61,1374],{"d":1375,"fill":64,"stroke":85,"style":55},"M244.0,218.0 C364.0,218.0 364.0,166.0 480.0,166.0",[87,1377],{"points":1378,"fill":90},"484.0,166.0 475.0,171.0 475.0,161.0",[39,1380],{"x":1330,"y":1381,"width":1331,"height":1332,"rx":52,"fill":161,"stroke":162,"style":55},"224.0",[57,1383,1385],{"transform":1384},"translate(603.0,237.0) scale(1.000)",[61,1386],{"d":1387,"fill":64,"stroke":169,"style":66},"M6 3h9l4 4v14H6z M15 3v4h4",[68,1389,1391],{"x":1341,"y":1390,"style":1367},"283.0","Log file",[68,1393,1395],{"x":1341,"y":1394,"style":1348},"302.0","full history",[61,1397],{"d":1398,"fill":64,"stroke":85,"style":55},"M244.0,218.0 C364.0,218.0 364.0,270.0 480.0,270.0",[87,1400],{"points":1401,"fill":90},"484.0,270.0 475.0,275.0 475.0,265.0",[39,1403],{"x":1330,"y":1404,"width":1331,"height":1332,"rx":52,"fill":128,"stroke":129,"style":55},"328.0",[57,1406,1408],{"transform":1407},"translate(603.0,341.0) scale(1.000)",[61,1409],{"d":199,"fill":64,"stroke":136,"style":66},[68,1411,183],{"x":1341,"y":1412,"style":1413},"387.0","font-size:13px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle",[68,1415,1417],{"x":1341,"y":1416,"style":1348},"406.0","when all else fails",[61,1419],{"d":1420,"fill":64,"stroke":85,"style":55},"M244.0,218.0 C364.0,218.0 364.0,374.0 480.0,374.0",[87,1422],{"points":1423,"fill":90},"484.0,374.0 475.0,379.0 475.0,369.0",[211,1425,1427],{"id":1426},"step-3-one-alert-per-incident-then-a-reminder-and-a-recovery","Step 3 — One alert per incident, then a reminder and a recovery",[10,1429,1430],{},"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:",[219,1432,1434],{"className":251,"code":1433,"language":253,"meta":224,"style":224},"STATE = Path(\"alert_state.json\")\nREMIND_AFTER = timedelta(hours=24)\n\ndef decide(ok, now, state_path=STATE):\n    state = json.loads(state_path.read_text()) if state_path.exists() else {}\n    action = None\n    if ok:\n        if \"failing_since\" in state:\n            action = \"recovered\"\n        state = {\"last_success\": now.isoformat()}\n    elif \"failing_since\" not in state:\n        action = \"failed\"\n        state.update(failing_since=now.isoformat(), last_alert=now.isoformat(), failures=1)\n    else:\n        state[\"failures\"] += 1\n        if now - datetime.fromisoformat(state[\"last_alert\"]) >= REMIND_AFTER:\n            action = \"still failing\"\n            state[\"last_alert\"] = now.isoformat()\n    state_path.write_text(json.dumps(state))\n    return action\n\nstart = datetime(2026, 9, 21, 6, tzinfo=timezone.utc)\noutcomes = [True, True] + [False] * 30 + [True, True]           # one run per hour\nfor hour, ok in enumerate(outcomes):\n    action = decide(ok, start + timedelta(hours=hour))\n    if action:\n        print(f\"{start + timedelta(hours=hour):%a %H:%M}  {action}\")\n# Mon 08:00  failed\n# Tue 08:00  still failing\n# Tue 14:00  recovered\n",[226,1435,1436,1452,1472,1476,1492,1514,1524,1531,1545,1555,1571,1585,1595,1625,1632,1646,1673,1682,1696,1701,1708,1713,1752,1805,1822,1843,1851,1911,1917,1923],{"__ignoreMap":224},[229,1437,1438,1441,1444,1447,1450],{"class":80,"line":231},[229,1439,1440],{"class":491},"STATE",[229,1442,1443],{"class":260}," =",[229,1445,1446],{"class":264}," Path(",[229,1448,1449],{"class":238},"\"alert_state.json\"",[229,1451,368],{"class":264},[229,1453,1454,1457,1459,1462,1465,1467,1470],{"class":80,"line":268},[229,1455,1456],{"class":491},"REMIND_AFTER",[229,1458,1443],{"class":260},[229,1460,1461],{"class":264}," timedelta(",[229,1463,1464],{"class":548},"hours",[229,1466,359],{"class":260},[229,1468,1469],{"class":491},"24",[229,1471,368],{"class":264},[229,1473,1474],{"class":80,"line":276},[229,1475,331],{"emptyLinePlaceholder":330},[229,1477,1478,1480,1483,1486,1488,1490],{"class":80,"line":284},[229,1479,379],{"class":260},[229,1481,1482],{"class":382}," decide",[229,1484,1485],{"class":264},"(ok, now, state_path",[229,1487,359],{"class":260},[229,1489,1440],{"class":491},[229,1491,1073],{"class":264},[229,1493,1494,1497,1499,1502,1505,1508,1511],{"class":80,"line":292},[229,1495,1496],{"class":264},"    state ",[229,1498,359],{"class":260},[229,1500,1501],{"class":264}," json.loads(state_path.read_text()) ",[229,1503,1504],{"class":260},"if",[229,1506,1507],{"class":264}," state_path.exists() ",[229,1509,1510],{"class":260},"else",[229,1512,1513],{"class":264}," {}\n",[229,1515,1516,1519,1521],{"class":80,"line":300},[229,1517,1518],{"class":264},"    action ",[229,1520,359],{"class":260},[229,1522,1523],{"class":491}," None\n",[229,1525,1526,1528],{"class":80,"line":314},[229,1527,918],{"class":260},[229,1529,1530],{"class":264}," ok:\n",[229,1532,1533,1536,1539,1542],{"class":80,"line":327},[229,1534,1535],{"class":260},"        if",[229,1537,1538],{"class":238}," \"failing_since\"",[229,1540,1541],{"class":260}," in",[229,1543,1544],{"class":264}," state:\n",[229,1546,1547,1550,1552],{"class":80,"line":334},[229,1548,1549],{"class":264},"            action ",[229,1551,359],{"class":260},[229,1553,1554],{"class":238}," \"recovered\"\n",[229,1556,1557,1560,1562,1565,1568],{"class":80,"line":348},[229,1558,1559],{"class":264},"        state ",[229,1561,359],{"class":260},[229,1563,1564],{"class":264}," {",[229,1566,1567],{"class":238},"\"last_success\"",[229,1569,1570],{"class":264},": now.isoformat()}\n",[229,1572,1573,1576,1578,1581,1583],{"class":80,"line":353},[229,1574,1575],{"class":260},"    elif",[229,1577,1538],{"class":238},[229,1579,1580],{"class":260}," not",[229,1582,1541],{"class":260},[229,1584,1544],{"class":264},[229,1586,1587,1590,1592],{"class":80,"line":371},[229,1588,1589],{"class":264},"        action ",[229,1591,359],{"class":260},[229,1593,1594],{"class":238}," \"failed\"\n",[229,1596,1597,1600,1603,1605,1608,1611,1613,1615,1618,1620,1623],{"class":80,"line":376},[229,1598,1599],{"class":264},"        state.update(",[229,1601,1602],{"class":548},"failing_since",[229,1604,359],{"class":260},[229,1606,1607],{"class":264},"now.isoformat(), ",[229,1609,1610],{"class":548},"last_alert",[229,1612,359],{"class":260},[229,1614,1607],{"class":264},[229,1616,1617],{"class":548},"failures",[229,1619,359],{"class":260},[229,1621,1622],{"class":491},"1",[229,1624,368],{"class":264},[229,1626,1627,1630],{"class":80,"line":389},[229,1628,1629],{"class":260},"    else",[229,1631,478],{"class":264},[229,1633,1634,1637,1640,1642,1644],{"class":80,"line":410},[229,1635,1636],{"class":264},"        state[",[229,1638,1639],{"class":238},"\"failures\"",[229,1641,1100],{"class":264},[229,1643,883],{"class":260},[229,1645,629],{"class":491},[229,1647,1648,1650,1653,1656,1659,1662,1665,1668,1671],{"class":80,"line":640},[229,1649,1535],{"class":260},[229,1651,1652],{"class":264}," now ",[229,1654,1655],{"class":260},"-",[229,1657,1658],{"class":264}," datetime.fromisoformat(state[",[229,1660,1661],{"class":238},"\"last_alert\"",[229,1663,1664],{"class":264},"]) ",[229,1666,1667],{"class":260},">=",[229,1669,1670],{"class":491}," REMIND_AFTER",[229,1672,478],{"class":264},[229,1674,1675,1677,1679],{"class":80,"line":645},[229,1676,1549],{"class":264},[229,1678,359],{"class":260},[229,1680,1681],{"class":238}," \"still failing\"\n",[229,1683,1684,1687,1689,1691,1693],{"class":80,"line":656},[229,1685,1686],{"class":264},"            state[",[229,1688,1661],{"class":238},[229,1690,1100],{"class":264},[229,1692,359],{"class":260},[229,1694,1695],{"class":264}," now.isoformat()\n",[229,1697,1698],{"class":80,"line":672},[229,1699,1700],{"class":264},"    state_path.write_text(json.dumps(state))\n",[229,1702,1703,1705],{"class":80,"line":692},[229,1704,634],{"class":260},[229,1706,1707],{"class":264}," action\n",[229,1709,1711],{"class":80,"line":1710},21,[229,1712,331],{"emptyLinePlaceholder":330},[229,1714,1716,1719,1721,1724,1727,1729,1732,1734,1737,1739,1742,1744,1747,1749],{"class":80,"line":1715},22,[229,1717,1718],{"class":264},"start ",[229,1720,359],{"class":260},[229,1722,1723],{"class":264}," datetime(",[229,1725,1726],{"class":491},"2026",[229,1728,1173],{"class":264},[229,1730,1731],{"class":491},"9",[229,1733,1173],{"class":264},[229,1735,1736],{"class":491},"21",[229,1738,1173],{"class":264},[229,1740,1741],{"class":491},"6",[229,1743,1173],{"class":264},[229,1745,1746],{"class":548},"tzinfo",[229,1748,359],{"class":260},[229,1750,1751],{"class":264},"timezone.utc)\n",[229,1753,1755,1758,1760,1763,1766,1768,1770,1772,1775,1777,1780,1782,1785,1788,1791,1793,1795,1797,1799,1802],{"class":80,"line":1754},23,[229,1756,1757],{"class":264},"outcomes ",[229,1759,359],{"class":260},[229,1761,1762],{"class":264}," [",[229,1764,1765],{"class":491},"True",[229,1767,1173],{"class":264},[229,1769,1765],{"class":491},[229,1771,1100],{"class":264},[229,1773,1774],{"class":260},"+",[229,1776,1762],{"class":264},[229,1778,1779],{"class":491},"False",[229,1781,1100],{"class":264},[229,1783,1784],{"class":260},"*",[229,1786,1787],{"class":491}," 30",[229,1789,1790],{"class":260}," +",[229,1792,1762],{"class":264},[229,1794,1765],{"class":491},[229,1796,1173],{"class":264},[229,1798,1765],{"class":491},[229,1800,1801],{"class":264},"]           ",[229,1803,1804],{"class":406},"# one run per hour\n",[229,1806,1808,1811,1814,1816,1819],{"class":80,"line":1807},24,[229,1809,1810],{"class":260},"for",[229,1812,1813],{"class":264}," hour, ok ",[229,1815,860],{"class":260},[229,1817,1818],{"class":491}," enumerate",[229,1820,1821],{"class":264},"(outcomes):\n",[229,1823,1825,1827,1829,1832,1834,1836,1838,1840],{"class":80,"line":1824},25,[229,1826,1518],{"class":264},[229,1828,359],{"class":260},[229,1830,1831],{"class":264}," decide(ok, start ",[229,1833,1774],{"class":260},[229,1835,1461],{"class":264},[229,1837,1464],{"class":548},[229,1839,359],{"class":260},[229,1841,1842],{"class":264},"hour))\n",[229,1844,1846,1848],{"class":80,"line":1845},26,[229,1847,918],{"class":260},[229,1849,1850],{"class":264}," action:\n",[229,1852,1854,1856,1858,1860,1862,1864,1866,1868,1870,1872,1874,1877,1880,1883,1886,1888,1891,1894,1897,1899,1902,1905,1907,1909],{"class":80,"line":1853},27,[229,1855,934],{"class":491},[229,1857,979],{"class":264},[229,1859,568],{"class":260},[229,1861,506],{"class":238},[229,1863,573],{"class":509},[229,1865,1718],{"class":264},[229,1867,1774],{"class":260},[229,1869,1461],{"class":264},[229,1871,1464],{"class":548},[229,1873,359],{"class":260},[229,1875,1876],{"class":264},"hour)",[229,1878,1879],{"class":260},":",[229,1881,1882],{"class":264},"%",[229,1884,1885],{"class":260},"a ",[229,1887,1882],{"class":264},[229,1889,1890],{"class":260},"H",[229,1892,1893],{"class":264},":%",[229,1895,1896],{"class":260},"M",[229,1898,585],{"class":509},[229,1900,1901],{"class":509},"  {",[229,1903,1904],{"class":264},"action",[229,1906,585],{"class":509},[229,1908,506],{"class":238},[229,1910,368],{"class":264},[229,1912,1914],{"class":80,"line":1913},28,[229,1915,1916],{"class":406},"# Mon 08:00  failed\n",[229,1918,1920],{"class":80,"line":1919},29,[229,1921,1922],{"class":406},"# Tue 08:00  still failing\n",[229,1924,1926],{"class":80,"line":1925},30,[229,1927,1928],{"class":406},"# Tue 14:00  recovered\n",[10,1930,1931],{},"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.",[20,1933,29,1938,29,1941,29,1944,29,1947,29,1953,29,1957,29,1962,29,1968,29,1971,29,1975,29,1979,29,1984,29,1991,29,1997,29,2001,29,2003,29,2005,29,2008,29,2014,29,2019,29,2022,29,2024,29,2026,29,2029,29,2035,29,2040,29,2043,29,2045],{"viewBox":1934,"role":23,"ariaLabelledBy":1935,"xmlns":27,"style":28},"4 0 752 279",[1936,1937],"al-streak-t","al-streak-d",[31,1939,1940],{"id":1936},"Thirty failed runs, three messages",[35,1942,1943],{"id":1937},"The hourly job fails from Monday 08:00. The first failure sends an alert. The following failures are suppressed until 24 hours have passed, when a single reminder goes out on Tuesday at 08:00. The first successful run on Tuesday at 14:00 sends a recovery message.",[39,1945],{"x":41,"y":42,"width":43,"height":1946,"fill":45},"279",[80,1948],{"x1":1347,"y1":1949,"x2":1950,"y2":1949,"stroke":1951,"style":1952},"139.5","666.0","var(--muted,#5b6780)","stroke-width:2.5px",[39,1954],{"x":48,"y":49,"width":1955,"height":1956,"rx":52,"fill":53,"stroke":54,"style":55},"172.0","91.5",[57,1958,1960],{"transform":1959},"translate(94.0,29.0) scale(1.000)",[61,1961],{"d":1319,"fill":64,"stroke":65,"style":66},[68,1963,1967],{"x":1964,"y":1965,"style":1966},"106.0","74.5","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Mon 08:00",[68,1969,1970],{"x":1964,"y":71,"style":1348},"failed → alert",[80,1972],{"x1":1964,"y1":1973,"x2":1964,"y2":1949,"stroke":1951,"style":1974},"107.5","stroke-width:1.5px",[1976,1977],"circle",{"cx":1964,"cy":1949,"r":1978,"fill":54,"stroke":45,"style":55},"7.0",[39,1980],{"x":1981,"y":1312,"width":1955,"height":1956,"rx":52,"fill":1982,"stroke":1983,"style":55},"202.7","#e7ebef","var(--line,#cdd5e6)",[57,1985,1987],{"transform":1986},"translate(276.7,184.5) scale(1.000)",[61,1988],{"d":1989,"fill":64,"stroke":1990,"style":66},"M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 .01 0z","var(--text,#172033)",[68,1992,1996],{"x":1993,"y":1994,"style":1995},"288.7","230.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Mon 09:00–Tue 07:00",[68,1998,2000],{"x":1993,"y":1999,"style":1348},"248.5","23 runs suppressed",[80,2002],{"x1":1993,"y1":1949,"x2":1993,"y2":1312,"stroke":1951,"style":1974},[1976,2004],{"cx":1993,"cy":1949,"r":1978,"fill":1951,"stroke":45,"style":55},[39,2006],{"x":2007,"y":49,"width":1955,"height":1956,"rx":52,"fill":128,"stroke":129,"style":55},"385.3",[57,2009,2011],{"transform":2010},"translate(459.3,29.0) scale(1.000)",[61,2012],{"d":2013,"fill":64,"stroke":136,"style":66},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2",[68,2015,2018],{"x":2016,"y":1965,"style":2017},"471.3","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Tue 08:00",[68,2020,2021],{"x":2016,"y":71,"style":1348},"still failing → reminder",[80,2023],{"x1":2016,"y1":1973,"x2":2016,"y2":1949,"stroke":1951,"style":1974},[1976,2025],{"cx":2016,"cy":1949,"r":1978,"fill":129,"stroke":45,"style":55},[39,2027],{"x":2028,"y":1312,"width":1955,"height":1956,"rx":52,"fill":161,"stroke":162,"style":55},"568.0",[57,2030,2032],{"transform":2031},"translate(642.0,184.5) scale(1.000)",[61,2033],{"d":2034,"fill":64,"stroke":169,"style":66},"M5 12.5l4.5 4.5L19 7",[68,2036,2039],{"x":2037,"y":1994,"style":2038},"654.0","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Tue 14:00",[68,2041,2042],{"x":2037,"y":1999,"style":1348},"recovered → notice",[80,2044],{"x1":2037,"y1":1949,"x2":2037,"y2":1312,"stroke":1951,"style":1974},[1976,2046],{"cx":2037,"cy":1949,"r":1978,"fill":162,"stroke":45,"style":55},[211,2048,2050],{"id":2049},"step-4-alert-when-the-job-did-not-run-at-all","Step 4 — Alert when the job did not run at all",[10,2052,2053],{},"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:",[219,2055,2057],{"className":251,"code":2056,"language":253,"meta":224,"style":224},"def check_heartbeat(state_path, expected_every, grace, now):\n    state = json.loads(state_path.read_text()) if state_path.exists() else {}\n    if \"last_success\" not in state:\n        return \"no successful run recorded\"\n    age = now - datetime.fromisoformat(state[\"last_success\"])\n    if age > expected_every + grace:\n        return f\"last success {age} ago\"\n    return None\n\nnow = start + timedelta(hours=len(outcomes) + 5)                  # the job stopped running\nprint(check_heartbeat(STATE, timedelta(hours=1), timedelta(minutes=30), now))\n# last success 6:00:00 ago\n",[226,2058,2059,2069,2085,2098,2105,2122,2140,2159,2165,2169,2204,2236],{"__ignoreMap":224},[229,2060,2061,2063,2066],{"class":80,"line":231},[229,2062,379],{"class":260},[229,2064,2065],{"class":382}," check_heartbeat",[229,2067,2068],{"class":264},"(state_path, expected_every, grace, now):\n",[229,2070,2071,2073,2075,2077,2079,2081,2083],{"class":80,"line":268},[229,2072,1496],{"class":264},[229,2074,359],{"class":260},[229,2076,1501],{"class":264},[229,2078,1504],{"class":260},[229,2080,1507],{"class":264},[229,2082,1510],{"class":260},[229,2084,1513],{"class":264},[229,2086,2087,2089,2092,2094,2096],{"class":80,"line":276},[229,2088,918],{"class":260},[229,2090,2091],{"class":238}," \"last_success\"",[229,2093,1580],{"class":260},[229,2095,1541],{"class":260},[229,2097,1544],{"class":264},[229,2099,2100,2102],{"class":80,"line":284},[229,2101,626],{"class":260},[229,2103,2104],{"class":238}," \"no successful run recorded\"\n",[229,2106,2107,2110,2112,2114,2116,2118,2120],{"class":80,"line":292},[229,2108,2109],{"class":264},"    age ",[229,2111,359],{"class":260},[229,2113,1652],{"class":264},[229,2115,1655],{"class":260},[229,2117,1658],{"class":264},[229,2119,1567],{"class":238},[229,2121,689],{"class":264},[229,2123,2124,2126,2129,2132,2135,2137],{"class":80,"line":300},[229,2125,918],{"class":260},[229,2127,2128],{"class":264}," age ",[229,2130,2131],{"class":260},">",[229,2133,2134],{"class":264}," expected_every ",[229,2136,1774],{"class":260},[229,2138,2139],{"class":264}," grace:\n",[229,2141,2142,2144,2146,2149,2151,2154,2156],{"class":80,"line":314},[229,2143,626],{"class":260},[229,2145,1122],{"class":260},[229,2147,2148],{"class":238},"\"last success ",[229,2150,573],{"class":509},[229,2152,2153],{"class":264},"age",[229,2155,585],{"class":509},[229,2157,2158],{"class":238}," ago\"\n",[229,2160,2161,2163],{"class":80,"line":327},[229,2162,634],{"class":260},[229,2164,1523],{"class":491},[229,2166,2167],{"class":80,"line":334},[229,2168,331],{"emptyLinePlaceholder":330},[229,2170,2171,2174,2176,2179,2181,2183,2185,2187,2190,2193,2195,2198,2201],{"class":80,"line":348},[229,2172,2173],{"class":264},"now ",[229,2175,359],{"class":260},[229,2177,2178],{"class":264}," start ",[229,2180,1774],{"class":260},[229,2182,1461],{"class":264},[229,2184,1464],{"class":548},[229,2186,359],{"class":260},[229,2188,2189],{"class":491},"len",[229,2191,2192],{"class":264},"(outcomes) ",[229,2194,1774],{"class":260},[229,2196,2197],{"class":491}," 5",[229,2199,2200],{"class":264},")                  ",[229,2202,2203],{"class":406},"# the job stopped running\n",[229,2205,2206,2208,2211,2213,2216,2218,2220,2222,2225,2228,2230,2233],{"class":80,"line":353},[229,2207,675],{"class":491},[229,2209,2210],{"class":264},"(check_heartbeat(",[229,2212,1440],{"class":491},[229,2214,2215],{"class":264},", timedelta(",[229,2217,1464],{"class":548},[229,2219,359],{"class":260},[229,2221,1622],{"class":491},[229,2223,2224],{"class":264},"), timedelta(",[229,2226,2227],{"class":548},"minutes",[229,2229,359],{"class":260},[229,2231,2232],{"class":491},"30",[229,2234,2235],{"class":264},"), now))\n",[229,2237,2238],{"class":80,"line":371},[229,2239,2240],{"class":406},"# last success 6:00:00 ago\n",[10,2242,2243],{},"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.",[211,2245,2247],{"id":2246},"step-5-test-the-alert-path-on-purpose","Step 5 — Test the alert path on purpose",[10,2249,2250],{},"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:",[219,2252,2254],{"className":251,"code":2253,"language":253,"meta":224,"style":224},"def test_failure_sends_alert(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    received = []\n    assert run_job(\"weekly-sales\", build_report, received.append) == 1\n    assert received[0][\"error\"].startswith(\"FileNotFoundError\")\n\nclass _MonkeyPatch:                       # stand-in for pytest's fixture in this demo\n    def chdir(self, path):\n        pass\n\ntest_failure_sends_alert(Path(\".\"), _MonkeyPatch())\nprint(\"alert path tested\")\n",[226,2255,2256,2266,2271,2280,2296,2317,2321,2335,2346,2351,2355,2366],{"__ignoreMap":224},[229,2257,2258,2260,2263],{"class":80,"line":231},[229,2259,379],{"class":260},[229,2261,2262],{"class":382}," test_failure_sends_alert",[229,2264,2265],{"class":264},"(tmp_path, monkeypatch):\n",[229,2267,2268],{"class":80,"line":268},[229,2269,2270],{"class":264},"    monkeypatch.chdir(tmp_path)\n",[229,2272,2273,2276,2278],{"class":80,"line":276},[229,2274,2275],{"class":264},"    received ",[229,2277,359],{"class":260},[229,2279,653],{"class":264},[229,2281,2282,2285,2287,2289,2292,2294],{"class":80,"line":284},[229,2283,2284],{"class":260},"    assert",[229,2286,664],{"class":264},[229,2288,365],{"class":238},[229,2290,2291],{"class":264},", build_report, received.append) ",[229,2293,924],{"class":260},[229,2295,629],{"class":491},[229,2297,2298,2300,2303,2305,2307,2309,2312,2315],{"class":80,"line":292},[229,2299,2284],{"class":260},[229,2301,2302],{"class":264}," received[",[229,2304,42],{"class":491},[229,2306,683],{"class":264},[229,2308,686],{"class":238},[229,2310,2311],{"class":264},"].startswith(",[229,2313,2314],{"class":238},"\"FileNotFoundError\"",[229,2316,368],{"class":264},[229,2318,2319],{"class":80,"line":300},[229,2320,331],{"emptyLinePlaceholder":330},[229,2322,2323,2326,2329,2332],{"class":80,"line":314},[229,2324,2325],{"class":260},"class",[229,2327,2328],{"class":234}," _MonkeyPatch",[229,2330,2331],{"class":264},":                       ",[229,2333,2334],{"class":406},"# stand-in for pytest's fixture in this demo\n",[229,2336,2337,2340,2343],{"class":80,"line":327},[229,2338,2339],{"class":260},"    def",[229,2341,2342],{"class":382}," chdir",[229,2344,2345],{"class":264},"(self, path):\n",[229,2347,2348],{"class":80,"line":334},[229,2349,2350],{"class":260},"        pass\n",[229,2352,2353],{"class":80,"line":348},[229,2354,331],{"emptyLinePlaceholder":330},[229,2356,2357,2360,2363],{"class":80,"line":353},[229,2358,2359],{"class":264},"test_failure_sends_alert(Path(",[229,2361,2362],{"class":238},"\".\"",[229,2364,2365],{"class":264},"), _MonkeyPatch())\n",[229,2367,2368,2370,2372,2375],{"class":80,"line":371},[229,2369,675],{"class":491},[229,2371,979],{"class":264},[229,2373,2374],{"class":238},"\"alert path tested\"",[229,2376,368],{"class":264},[10,2378,2379],{},"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.",[211,2381,2383],{"id":2382},"common-pitfalls","Common pitfalls",[2385,2386,2387,2399,2405,2411],"ul",{},[2388,2389,2390,2394,2395,2398],"li",{},[2391,2392,2393],"strong",{},"Catching errors deep inside the job."," A ",[226,2396,2397],{},"try\u002Fexcept"," around 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.",[2388,2400,2401,2404],{},[2391,2402,2403],{},"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.",[2388,2406,2407,2410],{},[2391,2408,2409],{},"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.",[2388,2412,2413,2416],{},[2391,2414,2415],{},"Sharing one state file between jobs."," Each job needs its own, or one job's recovery clears another's incident.",[211,2418,2420],{"id":2419},"conclusion","Conclusion",[10,2422,2423],{},"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.",[211,2425,2427],{"id":2426},"frequently-asked-questions","Frequently asked questions",[10,2429,2430,2433],{},[2391,2431,2432],{},"How do I get notified when a scheduled Python script fails?","\nWrap the job's entry point in a try\u002Fexcept 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.",[10,2435,2436,2439],{},[2391,2437,2438],{},"How do I stop an hourly job sending an alert every hour while it is broken?","\nKeep 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.",[10,2441,2442,2445],{},[2391,2443,2444],{},"How do I get alerted when a job did not run at all?","\nAn 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.",[10,2447,2448,2451],{},[2391,2449,2450],{},"What should an alert for a failed report contain?","\nThe 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.",[211,2453,2455],{"id":2454},"related","Related",[2385,2457,2458,2464,2471,2478],{},[2388,2459,2460,2461,2463],{},"Up one level: ",[14,2462,17],{"href":16}," — making report jobs dependable.",[2388,2465,2466,2470],{},[14,2467,2469],{"href":2468},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Flog-python-excel-script-output-to-a-file\u002F","Log Python Excel Script Output to a File"," — the log the alert points to.",[2388,2472,2473,2477],{},[14,2474,2476],{"href":2475},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it\u002F","Validate an Excel Report Before Sending It"," — failing on bad data, not just on exceptions.",[2388,2479,2480,2484],{},[14,2481,2483],{"href":2482},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed\u002F","Skip a Report Run When the Input File Has Not Changed"," — telling a stale input from a failure.",[2486,2487,2488],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}",{"title":224,"searchDepth":268,"depth":268,"links":2490},[2491,2492,2493,2494,2495,2496,2497,2498,2499,2500],{"id":213,"depth":268,"text":214},{"id":433,"depth":268,"text":434},{"id":718,"depth":268,"text":719},{"id":1426,"depth":268,"text":1427},{"id":2049,"depth":268,"text":2050},{"id":2246,"depth":268,"text":2247},{"id":2382,"depth":268,"text":2383},{"id":2419,"depth":268,"text":2420},{"id":2426,"depth":268,"text":2427},{"id":2454,"depth":268,"text":2455},"2026-09-23","Make a failing Excel report job tell someone — a top-level wrapper that turns any exception into an alert with the error, host and traceback, notification channels that cannot hide the original failure, one alert per failure streak plus reminders and a recovery notice, and a heartbeat check for jobs that never ran at all.","md",[2505,2507,2509,2511],{"q":2432,"a":2506},"Wrap the job's entry point in a try\u002Fexcept 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.",{"q":2438,"a":2508},"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.",{"q":2444,"a":2510},"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.",{"q":2450,"a":2512},"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.","send an alert when an excel report job fails",null,{"breadcrumb":2516},[2517,2520,2523],{"name":2518,"item":2519},"Home","\u002F",{"name":2521,"item":2522},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails",{"title":2526,"description":2527},"Send an Alert When a Python Excel Report Job Fails","Alert on failures in scheduled Python Excel jobs — wrap main() to catch and report exceptions, return a non-zero exit code, send email or webhook alerts with a stderr fallback, suppress repeat alerts with a state file, announce recovery and detect silent failures with a heartbeat.","send-an-alert-when-an-excel-report-job-fails","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails\u002Findex",[2531,2532],"reliable-jobs","email-delivery","how-to","qfE4wHD9UTwsIY-8CvnMq95yTd23Wk7EKSr-ewxrVXc",[2536,2539],{"title":714,"path":2537,"stem":2538,"children":-1},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002Findex",{"title":2540,"path":2541,"stem":2542,"children":-1},"Set Timeouts on Steps of an Excel Report Job","\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fset-timeouts-on-steps-of-an-excel-report-job","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fset-timeouts-on-steps-of-an-excel-report-job\u002Findex",[2544,2546],{"path":2545,"title":17},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation",{"path":2547,"title":2521},"\u002Fautomating-reporting-workflows",[],[2550,2554,2557,2560,2563,2566],{"path":2551,"title":2552,"kind":2553},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts","Testing and Packaging Excel Automation Scripts","guide",{"path":2555,"title":2556,"kind":2533},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook","Block Large or Sensitive Excel Files with a Pre-commit Hook",{"path":2558,"title":2559,"kind":2533},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse","Build a Command-Line Tool for Excel Reports with argparse",{"path":2561,"title":2562,"kind":2533},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcompare-excel-output-to-a-golden-file-in-tests","Compare Excel Output to a Golden File in Tests",{"path":2564,"title":2565,"kind":2533},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcreate-excel-test-fixtures-in-memory","Create Excel Test Fixtures in Memory for pytest",{"path":2567,"title":2568,"kind":2533},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver","Diff Excel Files in Git with a textconv Driver",1790972132167]