Errors at layer boundaries
Each layer should raise errors in its own vocabulary. A web handler should not catch sqlite3.IntegrityError, and the domain should not know HTTP status codes. At each boundary, catch what the inner layer raised and raise what the outer layer understands.
import sqlite3
class EmailTaken(Exception):
pass
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users"
" (email TEXT PRIMARY KEY)")
def add_user(email):
try:
db.execute("INSERT INTO users"
" VALUES (?)", (email,))
except sqlite3.IntegrityError as err:
raise EmailTaken(email) from err
add_user("ada@x.io")
try:
add_user("ada@x.io")
except EmailTaken as err:
print("taken:", err, "|", err.__cause__)from err keeps the original exception as __cause__, so the traceback still shows the database error underneath.
The outermost boundary
At the very edge, an error boundary turns known domain errors into responses, and anything unexpected into a generic failure. The details of that failure go to the logs, never to the user.
Your task
Write SqliteUsers(conn) whose add(email) raises EmailTaken chained from the IntegrityError and whose get(email) returns the email or raises UserNotFound. Write handle(action, *args) returning (200, result) on success, (409, message) for EmailTaken, (404, message) for UserNotFound, both messages naming the email, and (500, "internal error") for any other exception.
The explanation opens here once every check passes.