5 Useful DIY Python Functions for Error Handling

by SkillAiNest

5 Useful DIY Python Functions for Error Handling
Photo by author

# Introduction

Error handling is often a weak point in otherwise solid code. Problems like missing keys, failed requests, and long-running functions appear frequently in real projects. Python built-in try-except Blocks are useful, but they don’t cover many practical cases by themselves.

You’ll need to wrap common failure scenarios in small, reusable functions that help handle retries with limits, input validation, and safeguards that prevent code from running too long. This article walks through five error-handling functions that you can use in tasks like web scraping, application programming interfaces (APIs), processing user data, and more.

You can find the code on GitHub..

# Retrying failed operations with exponential backoff

In many projects, API calls and network requests often fail. A starting approach is to try once and catch any exceptions, log them, and stop. The best way is to try again.

This is where the exponential bake-off comes in. Instead of hammering a failed service with quick retries—which only makes things worse—you wait a little longer between each attempt: 1 second, then 2 seconds, then 4 seconds, and so on.

Let’s create a decorator that does this:

import time
import functools
from typing import Callable, Type, Tuple

def retry_with_backoff(
    max_attempts: int = 3,
    base_delay: float = 1.0,
    exponential_base: float = 2.0,
    exceptions: Tuple(Type(Exception), ...) = (Exception,)
):
    """
    Retry a function with exponential backoff.
    
    Args:
        max_attempts: Maximum number of retry attempts
        base_delay: Initial delay in seconds
        exponential_base: Multiplier for delay (2.0 = double each time)
        exceptions: Tuple of exception types to catch and retry
    """
    def decorator(func: Callable):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exception = e
                    
                    if attempt < max_attempts - 1:
                        delay = base_delay * (exponential_base ** attempt)
                        print(f"Attempt {attempt + 1} failed: {e}")
                        print(f"Retrying in {delay:.1f} seconds...")
                        time.sleep(delay)
                    else:
                        print(f"All {max_attempts} attempts failed")
            
            raise last_exception
        
        return wrapper
    return decorator

A decorator wraps your function and catches certain exceptions. The key is accounting. delay = base_delay * (exponential_base ** attempt). with the base_delay=1 And exponential_base=2your delays are 1s, 2s, 4s, 8s. This gives stressed systems time to recover.

The exceptions parameter lets you specify which errors to retry. You can try again. ConnectionError But no ValueErrorsince connection problems are temporary but validation errors are not.

Now let’s see it in action:

import random

@retry_with_backoff(max_attempts=4, base_delay=0.5, exceptions=(ConnectionError,))
def fetch_user_data(user_id):
    """Simulate an unreliable API."""
    if random.random() < 0.6:  # 60% failure rate
        raise ConnectionError("Service temporarily unavailable")
    return {"id": user_id, "name": "Sara", "status": "active"}

# Watch it retry automatically
result = fetch_user_data(12345)
print(f"Success: {result}")

Output:

Success: {'id': 12345, 'name': 'Sara', 'status': 'active'}

# Validating input with composable rules

Validating user input is tedious and repetitive. You check if the strings are empty, if the numbers are in range, and if the emails look correct. Before you know it, you’ve got if statements everywhere and your code looks like a mess.

Let’s create an authentication system that is easy to use. First, we need a custom exception:

from typing import Any, Callable, Dict, List, Optional

class ValidationError(Exception):
    """Raised when validation fails."""
    def __init__(self, field: str, errors: List(str)):
        self.field = field
        self.errors = errors
        super().__init__(f"{field}: {', '.join(errors)}")

This exception has multiple error messages. When validation fails, we want to show the user everything that is wrong, not just the first error.

Now here is the validator:

def validate_input(
    value: Any,
    field_name: str,
    rules: Dict(str, Callable((Any), bool)),
    messages: Optional(Dict(str, str)) = None
) -> Any:
    """
    Validate input against multiple rules.

    Returns the value if valid, raises ValidationError otherwise.
    """
    if messages is None:
        messages = {}

    errors = ()

    for rule_name, rule_func in rules.items():
        try:
            if not rule_func(value):
                error_msg = messages.get(
                    rule_name,
                    f"Failed validation rule: {rule_name}"
                )
                errors.append(error_msg)
        except Exception as e:
            errors.append(f"Validation error in {rule_name}: {str(e)}")

    if errors:
        raise ValidationError(field_name, errors)

    return value

i rules In a dictionary, each rule is simply a function that returns a function. True or False. This makes rules composable and reusable.

Let’s make some general validation rules:

# Reusable validation rules
def not_empty(value: str) -> bool:
    return bool(value and value.strip())

def min_length(min_len: int) -> Callable:
    return lambda value: len(str(value)) >= min_len

def max_length(max_len: int) -> Callable:
    return lambda value: len(str(value)) <= max_len

def in_range(min_val: float, max_val: float) -> Callable:
    return lambda value: min_val <= float(value) <= max_val

How to notice min_length, max_lengthand in_range There are factory functions. They return validation functions configured with specified parameters. It lets you write. min_length(3) Instead of creating a new function for each length requirement.

Let’s validate a username:

try:
    username = validate_input(
        "ab",
        "username",
        {
            "not_empty": not_empty,
            "min_length": min_length(3),
            "max_length": max_length(20),
        },
        messages={
            "not_empty": "Username cannot be empty",
            "min_length": "Username must be at least 3 characters",
            "max_length": "Username cannot exceed 20 characters",
        }
    )
    print(f"Valid username: {username}")
except ValidationError as e:
    print(f"Invalid: {e}")

Output:

Invalid: username: Username must be at least 3 characters

This approach scales well. Define your rules once, write them as you need them, and get clear error messages.

# Safely navigating nested dictionaries

Access to home dictionaries is often difficult. you get KeyError When the key does not exist, TypeError When you try to subscript a string, and you get cluttered with chains of code. .get() Calls or defenses try-except Working with JavaScript Object Notation (JSON) from blocks APIs makes it more difficult.

Let’s create a function that safely navigates nested structures:

from typing import Any, Optional, List, Union

def safe_get(
    data: dict,
    path: Union(str, List(str)),
    default: Any = None,
    separator: str = "."
) -> Any:
    """
    Safely get a value from a nested dictionary.

    Args:
        data: The dictionary to access
        path: Dot-separated path (e.g., "user.address.city") or list of keys
        default: Value to return if path doesn't exist
        separator: Character to split path string (default: ".")

    Returns:
        The value at the path, or default if not found
    """
    # Convert string path to list
    if isinstance(path, str):
        keys = path.split(separator)
    else:
        keys = path

    current = data

    for key in keys:
        try:
            # Handle list indices (convert string to int if numeric)
            if isinstance(current, list):
                try:
                    key = int(key)
                except (ValueError, TypeError):
                    return default

            current = current(key)

        except (KeyError, IndexError, TypeError):
            return default

    return current

The function splits the path into individual keys and navigates the nested structure step by step. If no key exists or if you try to subscribe to an object that isn’t subscribeable, it returns the default instead of crashing.

It also handles list indexes automatically. If the current value is a list and the key is numeric, it converts the key to an integer.

The companion function to set the values ​​is:

def safe_set(
    data: dict,
    path: Union(str, List(str)),
    value: Any,
    separator: str = ".",
    create_missing: bool = True
) -> bool:
    """
    Safely set a value in a nested dictionary.

    Args:
        data: The dictionary to modify
        path: Dot-separated path or list of keys
        value: Value to set
        separator: Character to split path string
        create_missing: Whether to create missing intermediate dicts

    Returns:
        True if successful, False otherwise
    """
    if isinstance(path, str):
        keys = path.split(separator)
    else:
        keys = path

    if not keys:
        return False

    current = data

    # Navigate to the parent of the final key
    for key in keys(:-1):
        if key not in current:
            if create_missing:
                current(key) = {}
            else:
                return False

        current = current(key)

        if not isinstance(current, dict):
            return False

    # Set the final value
    current(keys(-1)) = value
    return True

gave safe_set The function creates the nested structure and sets the value as needed. This is useful for dynamically building dictionaries.

Let’s examine both:

# Sample nested data
user_data = {
    "user": {
        "name": "Anna",
        "address": {
            "city": "San Francisco",
            "zip": "94105"
        },
        "orders": (
            {"id": 1, "total": 99.99},
            {"id": 2, "total": 149.50}
        )
    }
}

# Safe get examples
city = safe_get(user_data, "user.address.city")
print(f"City: {city}")

country = safe_get(user_data, "user.address.country", default="Unknown")
print(f"Country: {country}")

first_order = safe_get(user_data, "user.orders.0.total")
print(f"First order: ${first_order}")

# Safe set example
new_data = {}
safe_set(new_data, "user.settings.theme", "dark")
print(f"Created: {new_data}")

Output:

City: San Francisco
Country: Unknown
First order: $99.99
Created: {'user': {'settings': {'theme': 'dark'}}}

This pattern eliminates the clutter of defensive programming and cleans up your code when working with JSON, configuration files, or any deeply nested data.

# Enforcing timeout on long operations

Some operations take a long time. A database query can hang, a web scraping operation can get stuck on a slow server, or a calculation can run forever. You need a way to set time limits and bail out.

Here is a timeout decorator using threading:

import threading
import functools
from typing import Callable, Optional

class TimeoutError(Exception):
    """Raised when an operation exceeds its timeout."""
    pass

def timeout(seconds: int, error_message: Optional(str) = None):
    """
    Decorator to enforce a timeout on function execution.

    Args:
        seconds: Maximum execution time in seconds
        error_message: Custom error message for timeout
    """
    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = (TimeoutError(
                error_message or f"Operation timed out after {seconds} seconds"
            ))

            def target():
                try:
                    result(0) = func(*args, **kwargs)
                except Exception as e:
                    result(0) = e

            thread = threading.Thread(target=target)
            thread.daemon = True
            thread.start()
            thread.join(timeout=seconds)

            if thread.is_alive():
                raise TimeoutError(
                    error_message or f"Operation timed out after {seconds} seconds"
                )

            if isinstance(result(0), Exception):
                raise result(0)

            return result(0)

        return wrapper
    return decorator

This decorator runs and uses your function in a separate thread. thread.join(timeout=seconds) Waiting If the thread is still alive after the timeout, we know it took too long and extended TimeoutError.

The result of the function is stored in a list (variable container) so that the inner thread can modify it. If an exception has occurred in a thread, we raise it again in the main thread.

⚠️ A limit: Thread continues to run in background even after timeout. This is fine for most use cases, but for operations with side effects, be careful.

Let’s check it out:

import time

@timeout(2, error_message="Query took too long")
def slow_database_query():
    """Simulate a slow query."""
    time.sleep(5)
    return "Query result"

@timeout(3)
def fetch_data():
    """Simulate a quick operation."""
    time.sleep(1)
    return {"data": "value"}

# Test timeout
try:
    result = slow_database_query()
    print(f"Result: {result}")
except TimeoutError as e:
    print(f"Timeout: {e}")

# Test success
try:
    data = fetch_data()
    print(f"Success: {data}")
except TimeoutError as e:
    print(f"Timeout: {e}")

Output:

Timeout: Query took too long
Success: {'data': 'value'}

This pattern is essential for building responsive applications. When you’re scraping websites, calling external APIs, or running user code, a timeout prevents your program from hanging indefinitely.

# Resource management with automatic cleanup

Files, database connections, and opening network sockets require careful cleaning. If an exception occurs, you must ensure that the resource is released. Context Managers Using the with Statement handles this, but sometimes you need more control.

Let’s create a flexible context manager for automatic resource cleanup:

from contextlib import contextmanager
from typing import Callable, Any, Optional
import traceback

@contextmanager
def managed_resource(
    acquire: Callable((), Any),
    release: Callable((Any), None),
    on_error: Optional(Callable((Exception, Any), None)) = None,
    suppress_errors: bool = False
):
    """
    Context manager for automatic resource acquisition and cleanup.

    Args:
        acquire: Function to acquire the resource
        release: Function to release the resource
        on_error: Optional error handler
        suppress_errors: Whether to suppress exceptions after cleanup
    """
    resource = None
    try:
        resource = acquire()
        yield resource
    except Exception as e:
        if on_error and resource is not None:
            try:
                on_error(e, resource)
            except Exception as handler_error:
                print(f"Error in error handler: {handler_error}")

        if not suppress_errors:
            raise
    finally:
        if resource is not None:
            try:
                release(resource)
            except Exception as cleanup_error:
                print(f"Error during cleanup: {cleanup_error}")
                traceback.print_exc()

gave managed_resource A function is a context manager factory. It takes two required functions: one to acquire the resource and the other to release it. gave release The function runs forever. finally Block guarantees sanity even if exceptions occur.

optional on_error The parameter lets you handle errors before they propagate. This is useful for logging in, sending alerts, or attempting recovery. gave suppress_errors The flag determines whether exceptions are explicitly raised or suppressed.

Here’s a helpful class to demonstrate resource tracking:

class ResourceTracker:
    """Helper class to track resource operations."""

    def __init__(self, name: str, verbose: bool = True):
        self.name = name
        self.verbose = verbose
        self.operations = ()

    def log(self, operation: str):
        self.operations.append(operation)
        if self.verbose:
            print(f"({self.name}) {operation}")

    def acquire(self):
        self.log("Acquiring resource")
        return self

    def release(self):
        self.log("Releasing resource")

    def use(self, action: str):
        self.log(f"Using resource: {action}")

Let’s examine the context manager:

# Example: Operation with error handling
tracker = ResourceTracker("Database")

def error_handler(exception, resource):
    resource.log(f"Error occurred: {exception}")
    resource.log("Attempting rollback")

try:
    with managed_resource(
        acquire=lambda: tracker.acquire(),
        release=lambda r: r.release(),
        on_error=error_handler
    ) as db:
        db.use("INSERT INTO users")
        raise ValueError("Duplicate entry")
except ValueError as e:
    print(f"Caught: {e}")

Output:

(Database) Acquiring resource
(Database) Using resource: INSERT INTO users
(Database) Error occurred: Duplicate entry
(Database) Attempting rollback
(Database) Releasing resource
Caught: Duplicate entry

This pattern is useful for managing database connections, file handles, network sockets, locks, and any resources that need to be guaranteed sanity. This prevents resource leaks and secures your code.

# wrap up

Each function in this article addresses a specific error handling challenge: retrying transient failures, systematically validating input, safely accessing nested data, preventing hang operations, and managing resource cleanup.

These patterns appear repeatedly in API integration, data processing pipelines, web scraping, and user-facing applications.

Techniques here use decorators, context managers, and composable functions to make errors less frequent and more reliable. You can leave these functions as is in your projects or adapt them to your specific needs. They are self-contained, easy to understand, and solve problems you will encounter regularly. Happy coding!

Bala Priya c is a developer and technical writer from India. She loves working at the intersection of mathematics, programming, data science, and content creation. His areas of interest and expertise include DevOps, data science, and natural language processing. She enjoys reading, writing, coding and coffee! Currently, she’s working on learning lessons and sharing her knowledge with the developer community, writing tutorials, how-to guides, opinion pieces, and more. Bala also creates engaging resource reviews and coding tutorials.

You may also like

Leave a Comment

At Skillainest, we believe the future belongs to those who embrace AI, upgrade their skills, and stay ahead of the curve.

Get latest news

Subscribe my Newsletter for new blog posts, tips & new photos. Let's stay updated!

@2025 Skillainest.Designed and Developed by Pro