8. Error handling

Scrippy scripts are meant to run in production, where long stack traces scare users and leak information. In this step, you will see how Scrippy handles the errors your script does not handle, and how disk_watch.py handles the errors it can anticipate.

Unhandled errors

With the code of the previous step, check a mount point that does not exist:

./disk_watch.py --mount-point / /nope
[...]
[20261008 19:13:54] [INFO    ]  '-> /: 34.9% [OK]
[20261008 19:13:54] [INFO    ] [+] Workspace deletion: /home/luiggi/.local/share/scrippy/tmp/disk_watch_1791447234.2610497_29610
[20261008 19:13:54] [INFO    ] [+] End: 1 (FileNotFoundError)
[20261008 19:13:54] [CRITICAL] [FileNotFoundError]: [Errno 2] No such file or directory: '/nope'

The FileNotFoundError exception raised by shutil.disk_usage() is not handled by the script. It propagates up to the execution context, which:

  1. deletes the workspace and records the error in the history (End: 1 (FileNotFoundError)),
  2. logs a one line CRITICAL message made of the exception name and message, without stack trace,
  3. exits with the status 1.

In DEBUG level, the stack trace is displayed after the message, on the error output:

./disk_watch.py --mount-point / /nope --debug
[...]
[20261008 19:13:54] [CRITICAL] [FileNotFoundError]: [Errno 2] No such file or directory: '/nope'
  File "/home/luiggi/tutorial/disk_watch.py", line 130, in main
    results = check(get_mount_points(config, args), warning, critical)
  File "/home/luiggi/tutorial/disk_watch.py", line 89, in check
    usage = get_usage(mount_point)
  File "/home/luiggi/tutorial/disk_watch.py", line 82, in get_usage
    usage = shutil.disk_usage(mount_point)
  File "/usr/lib/python3.12/shutil.py", line 1350, in disk_usage
    st = os.statvfs(path)

Tip: Debug your scripts in a test environment, with the --debug option.

This default behavior guarantees a clean exit, but disk_watch.py stopped at the first faulty mount point and returned 1, which means WARNING for a monitoring tool. It can do better.

The code

#!/usr/bin/env python3
"""
--------------------------------------------------------------------------------
  @author         : Luiggi Vercotti
  @date           : 2026-10-08
  @version        : 0.8.0
  @description    : Monitor filesystem usage

--------------------------------------------------------------------------------
  Update:
  0.1.0  2026-10-08 - Luiggi Vercotti - cre: Script creation
  0.2.0  2026-10-08 - Luiggi Vercotti - evo: Several mount points, log levels
  0.3.0  2026-10-08 - Luiggi Vercotti - evo: Monitoring exit status
  0.4.0  2026-10-08 - Luiggi Vercotti - evo: Configuration file
  0.5.0  2026-10-08 - Luiggi Vercotti - evo: Command line options
  0.6.0  2026-10-08 - Luiggi Vercotti - evo: Alerting API token
  0.7.0  2026-10-08 - Luiggi Vercotti - evo: Report file
  0.8.0  2026-10-08 - Luiggi Vercotti - evo: Error handling

--------------------------------------------------------------------------------
  List of mandatory configuration parameters:
    @conf:filesystems|mount_points|str|false
    @conf:thresholds|warning|int|false
    @conf:thresholds|critical|int|false
    @conf:alerting|token|str|true

--------------------------------------------------------------------------------
  List of execution options and argument parameters:
    @args:mount-point|str|Mount points to check (default: from configuration)|+||exclude||false|false
    @args:exclude|str|Mount points to skip|+||||false|false
    @args:warning|int|Warning threshold in percent (default: from configuration)|1||||false|false
    @args:critical|int|Critical threshold in percent (default: from configuration)|1||||false|false
    @args:token|str|Alerting API token (default: from configuration)|1||||false|true
    @args:report|bool|Write a report into the report directory||false|||false|false
    @args:format|choice|Report format (default: text)|1|text,csv||report|false|false

--------------------------------------------------------------------------------
  Functioning:
  ---------------
    Checks the usage of each mount point against a warning and a critical
    threshold and exits with a monitoring compatible exit status:
      0: OK, 1: WARNING, 2: CRITICAL, 3: UNKNOWN (a mount point can not be checked)

    Configuration:
      - filesystems::mount_points: Comma separated list of mount points
      - thresholds::warning: Warning threshold in percent
      - thresholds::critical: Critical threshold in percent
      - alerting::token: Alerting API token (secret)
"""
# ------------------------------------------------------------------------------
#  Initialization of the environment
# ------------------------------------------------------------------------------
import os
import sys
import shutil
import datetime
import scrippy_core
from scrippy_core import logger, ScrippyCoreError

OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3
STATUS = {OK: "OK", WARNING: "WARNING", CRITICAL: "CRITICAL", UNKNOWN: "UNKNOWN"}
EXTENSIONS = {"text": "txt", "csv": "csv"}

# ------------------------------------------------------------------------------
#  Definition of functions and classes
# ------------------------------------------------------------------------------
def get_mount_points(config, args):
  if args.mount_point:
    return args.mount_point
  mount_points = [mp.strip() for mp in config.get("filesystems", "mount_points").split(",")]
  return [mp for mp in mount_points if mp not in (args.exclude or [])]

def get_thresholds(config, args):
  warning = args.warning if args.warning is not None else config.get("thresholds", "warning", "int")
  critical = args.critical if args.critical is not None else config.get("thresholds", "critical", "int")
  if not 0 < warning < critical <= 100:
    raise ScrippyCoreError(f"Invalid thresholds: warning={warning}, critical={critical}")
  return warning, critical

def get_usage(mount_point):
  logger.debug(f"[+] Checking {mount_point}")
  usage = shutil.disk_usage(mount_point)
  return round(usage.used * 100 / usage.total, 1)

def check(mount_points, warning, critical):
  results = []
  for mount_point in mount_points:
    try:
      usage = get_usage(mount_point)
    except OSError as err:
      logger.error(f" '-> {mount_point}: {err.strerror}")
      results.append((mount_point, None, UNKNOWN))
      continue
    if usage >= critical:
      logger.error(f" '-> {mount_point}: {usage}% [CRITICAL]")
      status = CRITICAL
    elif usage >= warning:
      logger.warning(f" '-> {mount_point}: {usage}% [WARNING]")
      status = WARNING
    else:
      logger.info(f" '-> {mount_point}: {usage}% [OK]")
      status = OK
    results.append((mount_point, usage, status))
  return results

def write_report(results, report_format, workspace_path, reportdir):
  name = f"disk_watch_{datetime.datetime.now():%Y%m%d_%H%M%S}.{EXTENSIONS[report_format]}"
  tmp_report = os.path.join(workspace_path, name)
  logger.info(f"[+] Writing report: {tmp_report}")
  with open(tmp_report, mode="w", encoding="utf-8") as report:
    for mount_point, usage, status in results:
      if report_format == "csv":
        report.write(f"{mount_point};{usage};{STATUS[status]}\n")
      else:
        report.write(f"{mount_point:<20} {usage}% {STATUS[status]}\n")
  report_dir = os.path.join(reportdir, "disk_watch")
  os.makedirs(report_dir, exist_ok=True)
  logger.info(f"[+] Publishing report into {report_dir}")
  shutil.move(tmp_report, os.path.join(report_dir, name))

# ------------------------------------------------------------------------------
#  Main processing
# ------------------------------------------------------------------------------
def main():
  with scrippy_core.ScriptContext(retention=100) as _context:
    config = _context.config
    args = _context.args
    token = args.token or config.get("alerting", "token")
    logger.debug(f"[+] Alerting API token: {token}")
    warning, critical = get_thresholds(config, args)
    logger.log_section("disk_watch", f"Thresholds: warning={warning}% critical={critical}%")
    results = check(get_mount_points(config, args), warning, critical)
    if args.report:
      write_report(results, args.format or "text", _context.workspace_path, _context.env["reportdir"])
    status = max(status for _, _, status in results)
    logger.info(f"[+] Status: {STATUS[status]}")
    if status != OK:
      sys.exit(status)

# ------------------------------------------------------------------------------
#  Entry point
# ------------------------------------------------------------------------------
if __name__ == '__main__':
  main()

What changed:

  • check() catches the OSError raised by get_usage(): The faulty mount point is logged as an error and gets the new UNKNOWN (3) status, then the next mount points are checked.
  • get_thresholds() checks the consistency of the thresholds and raises a ScrippyCoreError if they are invalid.
./disk_watch.py --mount-point / /nope
[...]
[20261008 19:14:03] [INFO    ]  '-> /: 34.9% [OK]
[20261008 19:14:03] [ERROR   ]  '-> /nope: No such file or directory
[20261008 19:14:03] [INFO    ] [+] Status: UNKNOWN
[20261008 19:14:03] [INFO    ] [+] Workspace deletion: /home/luiggi/.local/share/scrippy/tmp/disk_watch_1791447243.9663746_29643
[20261008 19:14:03] [INFO    ] [+] End: 3 (SystemExit)
[20261008 19:14:03] [CRITICAL] [SystemExit]: 3
./disk_watch.py --warning 95
[...]
[20261008 19:14:04] [INFO    ] [+] End: 1 (ScrippyCoreError)
[20261008 19:14:04] [CRITICAL] [ScrippyCoreError]: Invalid thresholds: warning=95, critical=90

Handling errors

Handle errors as close as possible

Catch an exception where you know what to do with it: check() knows that a faulty mount point must not prevent the others from being checked, so it handles the error itself.

Catch specific exceptions (OSError), not all exceptions: An unexpected error, i.e. a bug, must still stop the script.

Let the execution context handle fatal errors

When the script can not go on, there is no need to catch the error, log it and call sys.exit(1): Let the exception propagate, the execution context does exactly that.

ScrippyCoreError

To stop the script because of a condition detected by your code, raise a scrippy_core.ScrippyCoreError with a clear message:

from scrippy_core import ScrippyCoreError

raise ScrippyCoreError(f"Invalid thresholds: warning={warning}, critical={critical}")

Scrippy itself raises ScrippyCoreError for all its errors: Invalid header, invalid configuration, unauthorized user...

Each Scrippy module has its own exception class (i.e: ScrippyRemoteError for scrippy-remote), handled the same way.

Exit status

Situation Exit status Recorded in history
Normal end, sys.exit() or sys.exit(0) 0 Yes
sys.exit(n) n Yes
Unhandled exception, including ScrippyCoreError and KeyboardInterrupt (Ctrl+C) 1 Yes
Invalid configuration file, unauthorized user, concurrent execution refused 1 No
Invalid header declaration 1 No
Invalid command line options 2 No

Note: Errors in the main configuration file (missing key, invalid YAML) occur while importing scrippy_core, before Scrippy is able to handle errors: They are displayed with a Python stack trace.

What's next

disk_watch.py is robust. In the next step, you will restrict who is allowed to run it.