DEV Community

Cover image for Stop Swallowing Errors: A Guide to Professional Exception Handling in APIs
Rajat Bansal
Rajat Bansal

Posted on

Stop Swallowing Errors: A Guide to Professional Exception Handling in APIs

We all know try-except (or try-catch). It’s one of the first things we learn to stop our programs from crashing.

However, I often see fresher developers wrapping massive chunks of code in try-except blocks just for the sake of using them, often swallowing errors with a pass. While this keeps the server running, it creates unmaintainable code and hides critical bugs.

We need to be very careful about where we use it and where not to.

In this article, I will discuss the correct architecture for exception handling, specifically in the context of an ICR (Intelligent Character Recognition) API—a service that takes images or PDFs and extracts text from them. An API generally consists of two main parts: the Core Logic (the heavy lifting) and the API Route (the interface).

1. When NOT to use Try-Except: Core Logic vs. Coding Logic

The most important rule: Never use try-except for coding logic that can be handled with a simple if condition.

A common mistake is using exception handling to manage predictable data structure issues, like index-out-of-range errors when iterating through lists.

Suppose your ICR engine returns a list of words, and you assume that the value you want always follows the word 'Address'.

The Wrong Way (Lazy):
You might "try" to access the next index and just ignore it if it fails.

data = ["Name:", "John", "Address:"] # Address value is missing!
n = len(data)

try:
    # We assume the next index exists. If it doesn't, it crashes into the except block.
    for i in range(n):
      if data[i] == 'Address:':
          address = data[i+1] 
except IndexError:
    # Swallowing the error. The code proceeds, but 'address' might be undefined.
    pass 

Enter fullscreen mode Exit fullscreen mode

The Right Way (Logic):
If you can predict an error, write logic for it. Don't rely on the exception mechanism.

data = ["Name:", "John", "Address:"]
n = len(data)

for i in range(n):
  if data[i] == 'Address:':
      # Logic check: Ensure i+1 actually exists before accessing it
      if i + 1 < n:
         address = data[i+1]
      else:
         # Handle the missing data case gracefully without an exception
         address = None
         print("Found 'Address:' label but no value followed it.")

Enter fullscreen mode Exit fullscreen mode

In the second example, we control the flow with logic. It's clearer and faster.

2. Where we DO need Try-Except

We genuinely need try-except when dealing with things we cannot control—external dependencies.

In our ICR API context, this includes:

  • Calling an external OCR microservice.
  • Connecting to a database to log requests.
  • Reading a physical file from S3 or local disk.

These external systems can fail at any time for reasons unrelated to your code. This is where you must catch exceptions.


3. Designing the API: The "Wrong Way" to handle business rules

Now, let's talk about API architecture. Suppose our ICR API has a business rule: We do not accept PDFs longer than 10 pages because processing them takes too long.

A very common mistake junior developers make is handling these "business errors" in the core logic and returning string messages indicating failure.

Let's look at why this approach is messy.

The Core Logic (Bad Approach):

# --- core_icr_logic.py ---

def extract_data_from_pdf(pdf_file):
    # 1. Check page count constraint
    page_count = get_pdf_page_count(pdf_file)

    if page_count > 10:
        # THE BAD PRACTICE: Returning an error string
        return "Error: PDF has too many pages. Limit is 10."

    # 2. External Dependency: Call the heavy ICR engine
    try:
        extracted_text = call_heavy_icr_engine(pdf_file)
        return extracted_text
    except ConnectionError:
        # THE BAD PRACTICE: Returning another error string
        return "Error: ICR Engine is down."

Enter fullscreen mode Exit fullscreen mode

The API Route (Bad Approach):

Now look at how ugly the API route becomes. It has to inspect the returned string to figure out if it succeeded or failed.

# --- api_routes.py ---

@app.post("/extract")
def extract_api(file: UploadFile):
    result = extract_data_from_pdf(file)

    # The route has to guess what happened based on string matching. This is fragile.
    if isinstance(result, str) and result.startswith("Error: PDF has too many"):
         return {"msg": result}, 422 # Unprocessable Entity

    elif isinstance(result, str) and result.startswith("Error: ICR Engine"):
         return {"msg": result}, 503 # Service Unavailable

    # Assuming it's success if it's not an error string... dangerous assumption.
    return {"data": result}, 200

Enter fullscreen mode Exit fullscreen mode

Why is this bad?

  1. Hard to maintain: If you change the error string text in the core logic, you break the API route check.
  2. Mixed Concerns: The core logic shouldn't care about HTTP responses, and the route shouldn't have to parse strings to understand business rules.

4. The "Right Way": Custom Exceptions and Route Handling

The cleanest way to handle this is to separate concerns:

  1. Core Logic: Fails loudly by raising exceptions when business rules are violated.
  2. API Route: Is the only place that catches exceptions to determine the HTTP status code.

Step A: Create Custom Exceptions

Define exceptions specific to your business domain.

# --- exceptions.py ---
class PageLimitExceededException(Exception):
    """Raised when the PDF is too long for us to process"""
    pass

class ICREngineDownException(Exception):
    """Raised when the external ICR service is unreachable"""
    pass

Enter fullscreen mode Exit fullscreen mode

Step B: The Core Logic (Raises, never returns errors)

The core logic now only returns successful data. If something is wrong, it raises a specific flag (exception).

# --- core_icr_logic.py ---
from exceptions import PageLimitExceededException, ICREngineDownException

def extract_data_from_pdf(pdf_file):
    # 1. Check page count constraint
    page_count = get_pdf_page_count(pdf_file)

    if page_count > 10:
        # Just raise it and stop immediately. Don't return strings.
        raise PageLimitExceededException("PDF exceeds 10 page limit.")

    # 2. External Dependency
    try:
        extracted_text = call_heavy_icr_engine(pdf_file)
        return extracted_text
    except ConnectionError:
        # Wrap the low-level connection error in our domain exception
        raise ICREngineDownException("Could not connect to OCR service.")

Enter fullscreen mode Exit fullscreen mode

Step C: The API Route (The "Catch-All")

The route becomes incredibly clean. It tries the main operation and then has a specific bucket for every type of failure.

# --- api_routes.py ---
from exceptions import PageLimitExceededException, ICREngineDownException

@app.post("/extract")
def extract_api(file: UploadFile):
    try:
        # The happy path is very clear
        result = extract_data_from_pdf(file)
        return {"status": "success", "data": result}, 200

    except PageLimitExceededException as e:
        # Catch business rule violation -> 422
        return {"status": "error", "msg": str(e)}, 422

    except ICREngineDownException as e:
        # Catch external dependency failure -> 503
        # Log this critical error here as well
        print(f"CRITICAL: {e}") 
        return {"status": "error", "msg": "Service temporarily unavailable"}, 503

    except Exception as e:
        # Catch any unexpected bugs -> 500 generic server error
        print(f"UNEXPECTED ERROR: {e}")
        return {"status": "error", "msg": "Internal Server Error"}, 500

Enter fullscreen mode Exit fullscreen mode

Conclusion

By moving from "returning error strings" to "raising custom exceptions," your ICR API code becomes much easier to read and maintain.

  • Do not use try-except for logic that if/else can handle.
  • Never catch an exception in your core business logic just to return an error message. Raise it.
  • Catch exceptions only at the "entry point" (the API route) to decide which HTTP status code to send back to the client.

Top comments (0)