The scrippy-db module queries PostgreSQL, MySQL and SQLite databases through a single interface.
The reports of disk_watch.py show the usage of the filesystems at a given time. To see how fast a filesystem fills up, the measures must be kept. With scrippy-db, version 1.4.0 of disk_watch.py stores each measure in a database, and displays the evolution of each mount point since the previous check.
The [database] section of /home/luiggi/.local/share/scrippy/conf/disk_watch.conf is optional: Without it, the measures are not stored. With SQLite, the database is a simple file:
[database]
db_type = sqlite3
database = /home/luiggi/.local/share/scrippy/reports/disk_watch/measures.db
With PostgreSQL:
[database]
db_type = pgsql
database = monitoring
host = db.flying.circus
port = 5432
username = disk_watch
password = D34DP4RR0T
Starting from the version 1.3.0 of disk_watch.py, the module is imported, after scrippy_core:
import scrippy_core
from scrippy_db.db import Database
The queries are defined as constants. The placeholders of the parameters depend on the database type (see Query parameters):
# Measures database queries: Placeholders depend on the database type
CREATE_TABLE = "CREATE TABLE IF NOT EXISTS measures (date VARCHAR(19), name VARCHAR(255), percent REAL, status VARCHAR(8))"
SELECT_LAST = {"sqlite3": "SELECT percent FROM measures WHERE name = :name ORDER BY date DESC LIMIT 1",
"pgsql": "SELECT percent FROM measures WHERE name = %(name)s ORDER BY date DESC LIMIT 1",
"mysql": "SELECT percent FROM measures WHERE name = %(name)s ORDER BY date DESC LIMIT 1"}
INSERT = {"sqlite3": "INSERT INTO measures VALUES(:date, :name, :usage, :status)",
"pgsql": "INSERT INTO measures VALUES(%(date)s, %(name)s, %(usage)s, %(status)s)",
"mysql": "INSERT INTO measures VALUES(%(date)s, %(name)s, %(usage)s, %(status)s)"}
The new get_database() function builds the Database object from the configuration. The optional keys of the [database] section are not declared with @conf, so the password has to be registered as secret explicitly:
def get_database(config):
connection = {"db_type": config.get("database", "db_type"),
"database": config.get("database", "database")}
for key in ("username", "password", "host"):
if config.has("database", key):
connection[key] = config.get("database", key)
if config.has("database", "port"):
connection["port"] = config.get("database", "port", "int")
if "password" in connection:
# Undeclared configuration key: Its value has to be registered as secret explicitly
logger.add_secret(connection["password"])
return Database(**connection)
The new store_measures() function creates the table if needed, reads the previous measure of each mount point to display its evolution, then inserts all the new measures in a single transaction:
def store_measures(results, config):
db_type = config.get("database", "db_type")
date = f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}"
logger.info("[+] Storing measures")
with get_database(config) as db:
db.query(query=CREATE_TABLE, commit=True)
queries = []
for name, usage, status in results:
if usage is None:
continue
previous = db.query(query=SELECT_LAST[db_type], params={"name": name})
if previous:
logger.info(f" '-> {name}: {usage - previous[0][0]:+.1f}% since last check")
queries.append({"query": INSERT[db_type],
"params": {"date": date, "name": name, "usage": usage, "status": STATUS[status]}})
db.transaction(queries=queries, exit_on_error=True)
And main() stores the measures when the [database] section is present:
if config.has("database", key):
if config.has("database", "db_type"):
store_measures(results, config)
#!/usr/bin/env python3
"""
--------------------------------------------------------------------------------
@author : Luiggi Vercotti
@date : 2026-10-08
@version : 1.4.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
0.9.0 2026-10-08 - Luiggi Vercotti - evo: Access control
1.0.0 2026-10-08 - Luiggi Vercotti - evo: Concurrent executions
1.1.0 2026-10-08 - Luiggi Vercotti - evo: Report templates
1.2.0 2026-10-08 - Luiggi Vercotti - evo: Remote hosts, reports synchronization
1.3.0 2026-10-08 - Luiggi Vercotti - evo: SNMP appliances
1.4.0 2026-10-08 - Luiggi Vercotti - evo: Measures database
--------------------------------------------------------------------------------
List of authorized users or group:
@group:ops
--------------------------------------------------------------------------------
Concurrent executions:
@max_instance: 1
@exit_on_wait: true
--------------------------------------------------------------------------------
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
@conf:remote|user|str|false
@conf:remote|key|str|false
@conf:snmp|community|str|true
@conf:snmp|version|str|false
--------------------------------------------------------------------------------
List of execution options and argument parameters:
@args:host|str|Remote hosts to check over SSH (default: local host)|+||snmp-host||false|false
@args:snmp-host|str|Appliances to check over SNMP (default: local host)|+||host||false|false
@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,html||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)
- remote::user: SSH user name on the remote hosts
- remote::key: SSH private key file
- snmp::community: SNMP community (secret)
- snmp::version: SNMP version (1 or 2c)
- publish::host, publish::dir: Optional. Remote host and directory where
the report directory is synchronized over SFTP (same user and key as
remote hosts), in a subdirectory named after the local host
- publish::retention: Optional. Number of days the reports are kept
locally and on the publication host (default: 30)
- database::db_type, database::database: Optional. Database type
(sqlite3, pgsql or mysql) and name where the measures are stored
- database::username, database::password, database::host,
database::port: Optional. Database connection settings
Templates:
- <templatedir>/disk_watch/report.<format>.j2
"""
# ------------------------------------------------------------------------------
# Initialization of the environment
# ------------------------------------------------------------------------------
import os
import sys
import shutil
import datetime
import shlex
import socket
import scrippy_core
from scrippy_core import logger, ScrippyCoreError
from scrippy_template import template
from scrippy_remote import ScrippyRemoteError
from scrippy_remote.ssh import Ssh
from scrippy_remote.local import list_local_dir, delete_local_file
from scrippy_snmp import ScrippySnmpError
from scrippy_snmp.snmp import Snmp
from scrippy_db.db import Database
OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3
STATUS = {OK: "OK", WARNING: "WARNING", CRITICAL: "CRITICAL", UNKNOWN: "UNKNOWN"}
EXTENSIONS = {"text": "txt", "csv": "csv", "html": "html"}
REPORT_PATTERN = r"^disk_watch_\d{8}_\d{6}\.(txt|csv|html)$"
# HOST-RESOURCES-MIB::hrStorageTable columns
HR_STORAGE_DESCR = "1.3.6.1.2.1.25.2.3.1.3"
HR_STORAGE_SIZE = "1.3.6.1.2.1.25.2.3.1.5"
HR_STORAGE_USED = "1.3.6.1.2.1.25.2.3.1.6"
# Measures database queries: Placeholders depend on the database type
CREATE_TABLE = "CREATE TABLE IF NOT EXISTS measures (date VARCHAR(19), name VARCHAR(255), percent REAL, status VARCHAR(8))"
SELECT_LAST = {"sqlite3": "SELECT percent FROM measures WHERE name = :name ORDER BY date DESC LIMIT 1",
"pgsql": "SELECT percent FROM measures WHERE name = %(name)s ORDER BY date DESC LIMIT 1",
"mysql": "SELECT percent FROM measures WHERE name = %(name)s ORDER BY date DESC LIMIT 1"}
INSERT = {"sqlite3": "INSERT INTO measures VALUES(:date, :name, :usage, :status)",
"pgsql": "INSERT INTO measures VALUES(%(date)s, %(name)s, %(usage)s, %(status)s)",
"mysql": "INSERT INTO measures VALUES(%(date)s, %(name)s, %(usage)s, %(status)s)"}
# ------------------------------------------------------------------------------
# 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_local_usage(mount_point):
logger.debug(f"[+] Checking {mount_point}")
usage = shutil.disk_usage(mount_point)
return round(usage.used * 100 / usage.total, 1)
def get_remote_usage(ssh, mount_point):
logger.debug(f"[+] Checking {ssh.host}:{mount_point}")
result = ssh.exec(command=f"df -P {shlex.quote(mount_point)}", exit_on_error=False)
if result["exit_code"] != 0:
raise OSError(" ".join(result["stderr"]) or f"df exit code {result['exit_code']}")
# Filesystem 1024-blocks Used Available Capacity Mounted on
fields = result["stdout"][-1].split()
return round(int(fields[2]) * 100 / int(fields[1]), 1)
def get_snmp_usages(snmp):
"""Returns the usage of each storage of the appliance, indexed by its description."""
logger.debug(f"[+] Reading storage table of {snmp.host}")
columns = {}
for column in (HR_STORAGE_DESCR, HR_STORAGE_SIZE, HR_STORAGE_USED):
# OID suffix (storage index) => value
columns[column] = {oid.rsplit(".", 1)[1]: value for oid, value in snmp.walk(column)}
usages = {}
for index, descr in columns[HR_STORAGE_DESCR].items():
size = int(columns[HR_STORAGE_SIZE].get(index, 0))
if size > 0:
usages[descr] = round(int(columns[HR_STORAGE_USED][index]) * 100 / size, 1)
return usages
def get_snmp_usage(usages, mount_point):
try:
return usages[mount_point]
except KeyError as err:
raise OSError(f"No such storage: {mount_point}") from err
def check(mount_points, warning, critical, get_usage=get_local_usage, prefix=""):
results = []
for mount_point in mount_points:
name = f"{prefix}{mount_point}"
try:
usage = get_usage(mount_point)
except OSError as err:
logger.error(f" '-> {name}: {err.strerror or err}")
results.append((name, None, UNKNOWN))
continue
if usage >= critical:
logger.error(f" '-> {name}: {usage}% [CRITICAL]")
status = CRITICAL
elif usage >= warning:
logger.warning(f" '-> {name}: {usage}% [WARNING]")
status = WARNING
else:
logger.info(f" '-> {name}: {usage}% [OK]")
status = OK
results.append((name, usage, status))
return results
def check_remote_hosts(hosts, mount_points, warning, critical, config):
results = []
for host in hosts:
logger.info(f"[+] Checking remote host: {host}")
try:
with Ssh(username=config.get("remote", "user"),
host=host,
key=config.get("remote", "key"),
missing_host_key_policy="reject") as ssh:
results += check(mount_points, warning, critical,
get_usage=lambda mount_point: get_remote_usage(ssh, mount_point),
prefix=f"{host}:")
except ScrippyRemoteError as err:
logger.error(f" '-> {host}: {err}")
results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
return results
def check_snmp_hosts(hosts, mount_points, warning, critical, config):
results = []
for host in hosts:
logger.info(f"[+] Checking SNMP appliance: {host}")
try:
snmp = Snmp(host=host,
port=161,
community=config.get("snmp", "community"),
version=config.get("snmp", "version"))
usages = get_snmp_usages(snmp)
except ScrippySnmpError as err:
logger.error(f" '-> {host}: {err}")
results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
continue
results += check(mount_points, warning, critical,
get_usage=lambda mount_point: get_snmp_usage(usages, mount_point),
prefix=f"{host}:")
return results
def purge_reports(report_dir, retention):
"""Deletes the local reports older than ``retention`` days (the report date is part of its name)."""
limit = f"disk_watch_{datetime.datetime.now() - datetime.timedelta(days=retention):%Y%m%d_%H%M%S}"
logger.info(f"[+] Purging reports older than {retention} days")
for name in list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN):
if name < limit:
logger.info(f" '-> Deleting {name}")
delete_local_file(local_file=os.path.join(report_dir, name))
def sync_reports(report_dir, config):
"""Synchronizes the local reports to the publication host: Missing reports are sent, deleted reports are deleted."""
host = config.get("publish", "host")
remote_dir = f"{config.get('publish', 'dir')}/{socket.gethostname()}"
logger.info(f"[+] Synchronizing reports to {host}:{remote_dir}")
with Ssh(username=config.get("remote", "user"),
host=host,
key=config.get("remote", "key"),
missing_host_key_policy="reject") as ssh:
ssh.create_remote_dir(remote_dir=remote_dir, create_dirs=True)
local_reports = set(list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN))
remote_reports = set(ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=REPORT_PATTERN))
for name in sorted(local_reports - remote_reports):
logger.info(f" '-> Sending {name}")
ssh.put_file(local_file=os.path.join(report_dir, name), remote_dir=remote_dir)
for name in sorted(remote_reports - local_reports):
logger.info(f" '-> Deleting {name}")
ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
logger.info(f" '-> {len(local_reports - remote_reports)} sent, {len(remote_reports - local_reports)} deleted, {len(local_reports)} published")
def get_database(config):
connection = {"db_type": config.get("database", "db_type"),
"database": config.get("database", "database")}
for key in ("username", "password", "host"):
if config.has("database", key):
connection[key] = config.get("database", key)
if config.has("database", "port"):
connection["port"] = config.get("database", "port", "int")
if "password" in connection:
# Undeclared configuration key: Its value has to be registered as secret explicitly
logger.add_secret(connection["password"])
return Database(**connection)
def store_measures(results, config):
db_type = config.get("database", "db_type")
date = f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}"
logger.info("[+] Storing measures")
with get_database(config) as db:
db.query(query=CREATE_TABLE, commit=True)
queries = []
for name, usage, status in results:
if usage is None:
continue
previous = db.query(query=SELECT_LAST[db_type], params={"name": name})
if previous:
logger.info(f" '-> {name}: {usage - previous[0][0]:+.1f}% since last check")
queries.append({"query": INSERT[db_type],
"params": {"date": date, "name": name, "usage": usage, "status": STATUS[status]}})
db.transaction(queries=queries, exit_on_error=True)
def render_report(results, report_format, warning, critical, templatedir):
params = {"hostname": socket.gethostname(),
"date": f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
"warning": warning,
"critical": critical,
"status": STATUS[max(status for _, _, status in results)],
"results": [{"mount_point": mount_point, "usage": usage, "status": STATUS[status]}
for mount_point, usage, status in results]}
renderer = template.Renderer(os.path.join(templatedir, "disk_watch"),
f"report.{EXTENSIONS[report_format]}.j2")
return renderer.render(params)
def write_report(content, 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:
report.write(content)
report_dir = os.path.join(reportdir, "disk_watch")
os.makedirs(report_dir, exist_ok=True)
logger.info(f"[+] Publishing report into {report_dir}")
return 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}%")
mount_points = get_mount_points(config, args)
if args.host:
results = check_remote_hosts(args.host, mount_points, warning, critical, config)
elif args.snmp_host:
results = check_snmp_hosts(args.snmp_host, mount_points, warning, critical, config)
else:
results = check(mount_points, warning, critical)
if config.has("database", "db_type"):
store_measures(results, config)
if args.report:
report_format = args.format or "text"
content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
report = write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
if config.has("publish", "host"):
retention = config.get("publish", "retention", "int") if config.has("publish", "retention") else 30
purge_reports(os.path.dirname(report), retention)
sync_reports(os.path.dirname(report), config)
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()
[...]
[20261008 19:29:16] [INFO ] '-> /: 34.9% [OK]
[20261008 19:29:16] [INFO ] '-> /home: 61.2% [OK]
[20261008 19:29:16] [WARNING ] '-> /var: 83.7% [WARNING]
[20261008 19:29:16] [INFO ] [+] Storing measures
[20261008 19:29:16] [INFO ] '-> /: +0.1% since last check
[20261008 19:29:16] [INFO ] '-> /home: -2.4% since last check
[20261008 19:29:16] [INFO ] '-> /var: +5.2% since last check
[...]
The measures can then be used by any tool, i.e. a dashboard:
sqlite3 ~/.local/share/scrippy/reports/disk_watch/measures.db "select * from measures where name = '/var'"
2026-10-08 19:24:12|/var|78.5|OK
2026-10-08 19:29:16|/var|83.7|WARNING
pip install scrippy-db
scrippy-db requires Python 3.11 or later, psycopg2-binary and mysql-connector-python, installed automatically. SQLite support is provided by the Python standard library.
from scrippy_db.db import Database
Database(db_type, database=None, username=None, password=None, host=None, port=None, service=None)
| Parameter | Description |
|---|---|
db_type |
The database type: pgsql, mysql or sqlite3 |
database |
The database name, or the database file path for sqlite3 |
username, password, host, port |
The connection settings, ignored by sqlite3 |
service |
A PostgreSQL service name (pgsql only). When provided, the other connection settings are ignored |
Database is used as a context manager:
with Database(db_type="sqlite3", database="/home/luiggi/data/monty.db") as db:
rows = db.query(query="SELECT name FROM sketches")
A new connection is opened for each query(), multi_query() or transaction() call, and closed right after.
Tip: Store the connection settings in the configuration file, with the password declared as secret, or use a PostgreSQL service file, which keeps the password out of the script configuration.
DbFromConnectionChain creates a Database from a single string:
<db_type>:<username>/<password>@<host>:<port>//<database>
from scrippy_db.db import DbFromConnectionChain
with DbFromConnectionChain("pgsql:luiggi/D3ADP4RR0T@db.flying.circus:5432//monty_python") as db:
...
The password may contain any character. The username may not contain /, the host may not contain @ nor /, and the port must be numeric. An invalid string raises a ScrippyDbError.
query()rows = db.query(query, params=None, commit=False, verbose=False, exit_on_error=True)
Executes a single query and returns the rows as a list of tuples. The list is empty when the query returns no rows (INSERT, UPDATE, SELECT without match...).
| Parameter | Description | Default |
|---|---|---|
query |
The query, with placeholders | |
params |
The query parameters | None |
commit |
Commit the query. Without it, any change is discarded | False |
verbose |
Log the query, its parameters and the returned rows, in INFO level |
False |
exit_on_error |
On error, raise a ScrippyDbError after rollback. When False, the error is logged, the query rolled back and None returned |
True |
rows = db.query(query="SELECT name, age FROM people WHERE age > :age", params={"age": 40})
for name, age in rows:
logger.info(f"{name} is {age} years old")
db.query(query="DELETE FROM people WHERE age > :age", params={"age": 100}, commit=True)
multi_query()db.multi_query(query, paramlist, commit=False, verbose=False, exit_on_error=False)
Executes the same query once per set of parameters of paramlist. Intended to insert or update data, it returns None.
commit=True, each execution is committed right after it.commit=True, the executions preceding the error remain committed.exit_on_error is False by default: The error is only logged.paramlist = [{"name": "Harry Fink", "age": 42}, {"name": "Eric Praline", "age": 50}]
db.multi_query(query="INSERT INTO people VALUES(:name, :age)", paramlist=paramlist,
commit=True, exit_on_error=True)
transaction()db.transaction(queries, verbose=False, exit_on_error=False)
Executes several queries as a single transaction: They are all committed if none fails, otherwise they are all rolled back. Intended to insert or update data, it returns None.
queries is a list of dictionaries, each one with a query item and an optional params item. exit_on_error is False by default: The error is only logged.
db.transaction(queries=[{"query": "INSERT INTO people VALUES(:name, :age)",
"params": {"name": "Harry Fink", "age": 42}},
{"query": "DELETE FROM people WHERE age > :age",
"params": {"age": 100}}],
exit_on_error=True)
Warning: multi_query() and transaction() do not raise errors by default. Use exit_on_error=True unless a failure is acceptable.
To prevent SQL injection, queries must use placeholders and never embed the values themselves. The values are passed as a dictionary in params. The placeholder format depends on the database type:
db_type |
Placeholder | Example |
|---|---|---|
pgsql, mysql |
%(name)s |
SELECT * FROM people WHERE name = %(name)s |
sqlite3 |
:name |
SELECT * FROM people WHERE name = :name |
db.query(query="SELECT * FROM people WHERE name = %(name)s", params={"name": "Harry Fink"})
Warning: Never build a query with an f-string or string concatenation from external values.
Query errors raise a scrippy_db.ScrippyDbError (unless exit_on_error=False), after the error has been logged and the query rolled back:
| Message | Cause |
|---|---|
[UnsupportedDatabaseType] Unsupported database type: <db_type> (...) |
Unknown db_type, raised at instantiation |
[ConnectionChainError] Invalid connection chain. Expected format: ... |
Invalid connection chain |
[DatabaseQueryError] An error occurred while querying the database: <error> |
The query failed |
[20261008 19:29:26] [ERROR ] [OperationalError] no such table: measures
[20261008 19:29:26] [ERROR ] Rollbacking...
[20261008 19:29:26] [CRITICAL] [ScrippyDbError]: [DatabaseQueryError] An error occurred while querying the database: no such table: measures
Note: Connection errors (unreachable server, wrong password, unwritable SQLite file...) are not converted: They raise the exception of the database driver, i.e: sqlite3.OperationalError or psycopg2.OperationalError, whatever the value of exit_on_error.