4.7. Error Handling
Table of Contents
Why Error Handling Matters
When you write code, things will go wrong. Files might not exist, user input might be invalid, network requests may fail, or your own logic might have bugs. Good backend code does not crash on the first problem. It:
- Detects errors
- Handles them in a controlled way
- Reports them clearly
- Recovers when possible
Error handling is the set of tools and patterns you use to do this.
Backend systems must be especially robust, because they run for a long time and serve many users. A single unhandled error can crash the whole application or return confusing responses.
Key idea: You cannot avoid all errors.
You must design your code to expect and handle errors.
In this chapter you will learn general concepts that apply to many programming languages, and you will see examples written in a Python-like style because you will later use Python for backend development. The concepts, however, are language independent.
Types of Errors
There are several categories of errors you will meet often.
Syntax errors
These are mistakes in how the code is written. The language parser cannot understand the program at all.
Examples:
# Missing closing parenthesis
print("Hello"
# Wrong indentation
if x > 0:
print("positive")Syntax errors are caught before the program runs. The interpreter or compiler stops immediately with a message like "SyntaxError".
You usually fix these during development, not by handling them at runtime.
Runtime errors (exceptions)
The code is syntactically valid, but something bad happens while it runs. In many languages this creates an exception.
Examples:
# Division by zero
result = 10 / 0 # ZeroDivisionError in Python
# Accessing undefined variable
print(user_age) # NameError if user_age was never defined
# Index out of range
numbers = [1, 2, 3]
print(numbers[10]) # IndexErrorThese errors appear only when that line of code is executed. Different inputs may or may not trigger them.
Logical errors (bugs)
The code runs without crashing, but it does the wrong thing. The program's logic is incorrect.
Examples:
# Want to give 10% discount, but used addition instead of multiplication
price = 100
discounted_price = price + 0.10 # Bug: should be price * 0.90
# Using > instead of >=
if age > 18:
print("Allowed") # Age 18 is not allowed, maybe you wanted >=Logical errors are the hardest to detect. Error handling patterns are less helpful here. Testing and careful design are more important.
External errors (I/O, network, environment)
These occur when your code interacts with the outside world.
Common sources:
- Files, directories, permissions
- Network connections, timeouts
- Databases
- Environment variables
- External APIs
Example, file access:
file = open("config.json", "r") # Fails if file missing or no permissionExample, environment variable:
db_url = os.environ["DATABASE_URL"] # Fails if variable is not setExternal errors are normal in backend systems. You almost always need to handle them.
Exceptions and Control Flow
Most modern languages use exceptions to represent runtime errors. Even if the syntax differs, the main ideas are similar.
What is an exception?
An exception is a special object that represents an error or unusual situation. When the error happens, the current function stops and control jumps to the nearest error handler in the call stack.
If there is no handler, the program usually crashes.
Conceptually:
- Code runs normally.
- A problem is detected, for example division by zero.
- The language raises or throws an exception.
- The runtime looks for a piece of code that can catch or handle that exception.
- If found, it runs the handler. If not, the program stops with an error.
Call stack and exception propagation
Imagine this call chain:
def a():
b()
def b():
c()
def c():
x = 10 / 0 # Error here
a()
What happens when 10 / 0 is executed:
craises an exception.bdoes not handle it, so the error goes up tob's caller.adoes not handle it either.- The top-level caller (the runtime) receives an unhandled exception, so it terminates the program.
This process is called propagation up the call stack.
You can choose at which level to handle a specific error. Sometimes it is best to handle it near where it occurred. Other times it is better to let it bubble up to a higher layer, for example a web request handler that will convert it to an HTTP response.
Basic Exception Handling Pattern
The usual pattern looks like this (in Python-like pseudocode):
try:
# Code that might fail
result = risky_operation()
except SomeErrorType as error:
# Handle that specific error
handle_error(error)
finally:
# Optional, clean up resources
cleanup()
Most languages have similar constructs, but with different keywords, such as try / catch / finally in JavaScript or Java.
The try block
Inside try you put code that might raise an exception:
try:
age = int(user_input) # Might fail for "abc"
print(10 / age) # Might fail for 0
If everything works, the except block is skipped and the program continues after the try / except section.
If an exception happens at some line in the try block, execution jumps out of the try block immediately. Later lines inside try are not executed.
The except (catch) block
The except block runs only if an exception of the specified type (or a compatible subtype) happens in the try block.
try:
age = int(user_input)
except ValueError as e:
print("Please enter a valid number.")
If user_input is "25", there is no error, so except is skipped.
If user_input is "abc", int("abc") raises a ValueError, so the except block runs.
You can have multiple except blocks:
try:
result = 10 / int(user_input)
except ValueError:
print("Not a number.")
except ZeroDivisionError:
print("Cannot divide by zero.")The first matching block is used.
Important rule: Catch specific exception types when possible.
Avoid catching all errors without understanding them.
The finally block
finally is used for cleanup code that must run whether or not there was an error.
Example:
file = open("data.txt", "r")
try:
content = file.read()
process(content)
except IOError:
print("Could not read file.")
finally:
file.close() # Always executed
Even if read or process fails, file.close() still executes.
Common uses for finally:
- Closing files
- Releasing network connections
- Releasing locks
- Resetting global state
Handling Specific vs General Exceptions
Choosing what to catch is a key skill.
Specific exceptions
Good:
try:
user_id = int(user_input)
except ValueError:
print("User ID must be a number.")You know exactly what error you are handling and why.
Better example:
try:
user = db.get_user(user_id)
except UserNotFoundError:
return "User not found", 404You catch a very specific problem and convert it to a precise response.
General exceptions
Many languages have a base exception type, like Exception in Python.
You can catch all exceptions:
try:
risky_operation()
except Exception as e:
log_error(e)
return "Something went wrong"This can be useful at the top level of your application to ensure that no error crashes the server without logging.
However, if you do this in many places, you may:
- Hide important bugs
- Make debugging much harder
- Respond with vague error messages
Rule: At lower levels (inside functions and modules), catch only exceptions you expect and know how to handle.
Use a broad catch only at boundaries, for example request handlers, to log and fail gracefully.
Custom Error Types
Most languages allow you to define your own error or exception types. This is very useful in backend development.
Why create custom errors?
Reasons:
- Give errors meaningful names
- Carry extra data, for example HTTP status codes or user ids
- Make it easy to distinguish between different failure cases
Example of custom exception in Python-like code:
class UserNotFoundError(Exception):
def __init__(self, user_id):
self.user_id = user_id
super().__init__(f"User with id {user_id} not found")Usage:
def get_user(user_id):
user = db.find_user_by_id(user_id)
if user is None:
raise UserNotFoundError(user_id)
return user
try:
user = get_user(123)
except UserNotFoundError as e:
print(e) # "User with id 123 not found"Now you can have centralized handling:
try:
user = get_user(user_id)
# ...
except UserNotFoundError:
return "User not found", 404
except PermissionDeniedError:
return "Access denied", 403Returning Error Codes vs Throwing Exceptions
Not all languages or styles use exceptions heavily. Sometimes functions return values that indicate success or failure.
Error codes and special values
A function might return:
- A special value, for example
None,null,-1 - A pair, for example
(result, error) - A dedicated
Resulttype
Example with special value:
def find_user(name):
# returns user object or None
...
user = find_user("alice")
if user is None:
print("No such user")
else:
print("Found", user)This style can be clear if you always remember to check the result. The risk is that you forget, and the code continues in an invalid state.
Example with a (result, error) pair:
user, error = find_user("alice")
if error is not None:
print("Error:", error)
else:
print("Found", user)Some languages, like Go, use this pattern heavily.
Exceptions vs return-based error handling
Both approaches have pros and cons.
| Aspect | Exceptions | Return-based errors |
|---|---|---|
| Normal control flow | Clean, no extra checks on success paths | Every call needs checks |
| Locality of error handling | Can be handled far away through call stack | Must propagate or handle at each step |
| Performance | Sometimes a bit slower when raised frequently | Usually very predictable |
| Explicitness | Hidden in function signature | Visible in return types / result values |
In many backend projects:
- Exceptions are used for unexpected or "exceptional" failures.
- Return values indicate common cases, like "not found" vs "found".
Example design:
def find_user(user_id):
# None means "not found", this is not an error
# An exception means "could not access database", this is an error
...Guard Clauses and Early Returns
Error handling is not only about exceptions. It is also about structuring code clearly.
One useful pattern is the guard clause. Instead of nesting if statements deeply, you exit the function early when you detect invalid input or an error condition.
Example without guard clauses:
def process_order(order):
if order is not None:
if order.is_paid:
if order.items:
# long processing code
...
else:
print("Order has no items")
else:
print("Order not paid")
else:
print("Order is missing")Same logic with guard clauses:
def process_order(order):
if order is None:
print("Order is missing")
return
if not order.is_paid:
print("Order not paid")
return
if not order.items:
print("Order has no items")
return
# Here we know order is valid
# long processing code
...Both handle errors, but the guard clause version is easier to read and maintain.
Resource Cleanup and Avoiding Leaks
Backend applications often use resources that must be released:
- Files
- Database connections
- Network sockets
- Locks
If you do not release them, your application may:
- Run out of file descriptors
- Exhaust the database connection pool
- Deadlock due to stuck locks
You must ensure that cleanup always happens, even when errors occur.
Cleanup with finally
We saw a simple example before:
conn = open_database_connection()
try:
data = conn.query("SELECT ...")
process(data)
except DatabaseError:
log("Database error")
finally:
conn.close() # Always executed
If query or process raises an exception, close still runs.
Using context managers (RAII-like patterns)
Many languages offer a pattern where acquiring and releasing resources is automatic.
For example, in Python:
with open("file.txt", "r") as file:
content = file.read()
process(content)
# File is closed automatically, even if an error occursIn other languages, you might see similar constructs or patterns like RAII ("Resource Acquisition Is Initialization").
The idea is the same:
Rule: Whenever you acquire a resource, pair it with code that always releases it, even if an error occurs.
Error Messages, Logging, and Users
Backend code usually serves two audiences when something goes wrong:
- Developers and operators, who need technical details to debug.
- End users, who need simple, safe, non-technical messages.
You need to separate these concerns.
Internal vs external error information
When logging:
try:
process_payment(order_id)
except PaymentGatewayError as e:
log_error(f"Payment failed for order {order_id}: {e}")
return "Payment could not be processed. Please try again later."- The log message can include exception type, stack trace, and details.
- The user-facing message should be short, polite, and not leak sensitive data.
Do not show the raw database error or stack trace to users.
What to include in logs
For exceptions, logs should ideally contain:
- Error type and message
- Stack trace (where in code it happened)
- Context information, for example:
- User id (if authenticated)
- Request id
- URL endpoint
- Parameters (mask sensitive ones)
- Timestamp
This helps you debug production issues.
Later chapters on Logging and Monitoring will expand this.
Designing Error Handling in Layers
In a real backend you will have layers such as:
- HTTP layer (web framework)
- Service layer (business logic)
- Data access layer (database, external APIs)
Each layer should:
- Convert low-level errors into higher-level ones when needed
- Hide implementation details from upper layers
- Expose meaningful errors to callers
Example of layered error handling:
class UserNotFoundError(Exception):
...
class DatabaseConnectionError(Exception):
...
def get_user_from_db(user_id):
try:
return db.query_user(user_id)
except LowLevelDbConnectionError as e:
# Convert low-level error to domain-specific one
raise DatabaseConnectionError("Could not connect to user database") from e
def get_user_profile(user_id):
user = get_user_from_db(user_id)
if user is None:
raise UserNotFoundError(user_id)
return build_profile(user)
def handle_get_profile_request(request):
user_id = request.params["user_id"]
try:
profile = get_user_profile(user_id)
return json_response(profile, status=200)
except UserNotFoundError:
return json_response({"error": "User not found"}, status=404)
except DatabaseConnectionError:
return json_response({"error": "Service temporarily unavailable"}, status=503)Notes:
- The data access layer turns raw database errors into a more generic
DatabaseConnectionError. - The service layer raises
UserNotFoundErrorwhen a record is missing. - The HTTP layer catches these and maps them to HTTP status codes and JSON responses.
This is the kind of structure you want in backend applications.
Common Error Handling Anti‑Patterns
Some habits will hurt your code quality and debugging experience.
Swallowing exceptions silently
Bad:
try:
send_email(user)
except Exception:
pass # Do nothingNow you will never know that emails are failing.
Better:
try:
send_email(user)
except EmailServerError as e:
log_error(f"Failed to send email to {user.id}: {e}")
# maybe retry later or mark email as failedOverusing general exception handlers
Bad:
try:
do_many_things()
except Exception:
return "Error"You lose information about what failed. You cannot decide different actions for different errors.
Catch specific exceptions where you know what to do about them.
Using exceptions for normal control flow
Exceptions should represent exceptional situations, not common paths.
Bad:
try:
user = db.get_user(user_id)
except UserNotFoundError:
create_default_user(user_id)If "user not found" is expected and common, often a return value is a better choice:
user = db.get_user(user_id) # returns None if not found
if user is None:
create_default_user(user_id)Ignoring function contracts
If a function can raise an exception or return a special value, the caller must respect that.
Bad:
# find_user might return None
user = find_user("alice")
print(user.name) # Might raise an attribute errorGood:
user = find_user("alice")
if user is None:
print("User not found")
else:
print(user.name)Practice Exercises
Try to solve these in a language of your choice, ideally Python, but focus on the concepts.
- Safe division function
Write a function safe_divide(a, b) that:
- Returns
a / bwhenbis not zero. - Returns
Nonewhenbis zero. - Does not crash when
aorbcannot be converted to numbers, but returnsNoneinstead.
Then write code that calls safe_divide and prints either the result or "Invalid division" based on whether the result is None.
- File reading with error handling
Write a function read_config(path) that:
- Tries to open and read a file.
- If the file is missing, logs a message and returns an empty dictionary.
- If the file contains invalid JSON, logs a message and returns an empty dictionary.
- Otherwise returns the parsed config.
Make sure the file is always closed, even on errors.
- Custom error type
Define a custom exception InvalidAgeError. Write a function register_user(name, age) that:
- Raises
InvalidAgeErrorifageis less than 0 or greater than 120. - Otherwise returns some simple user object.
Write code that calls register_user for several test values and catches InvalidAgeError to print friendly error messages.
- Layered error handling sketch
Design three functions:
load_user_record(user_id)which simulates reading from a database and can raise a low-levelDatabaseError.get_user(user_id)which callsload_user_recordand:- Raises
UserNotFoundErrorif no record is found. - Converts
DatabaseErrorto a higher-levelUserServiceUnavailableError. handle_get_user_request(user_id)which callsget_userand returns:"200 OK"if the user exists."404 Not Found"if user is not found."503 Service Unavailable"if the user service is unavailable.
Implement these functions and test different scenarios.
Working through these will make the abstract ideas concrete, and you will be ready to apply them in real backend code later in the course.
Views: 9
KAHIBARO