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
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.")
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."
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
Why is this bad?
- Hard to maintain: If you change the error string text in the core logic, you break the API route check.
- 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:
- Core Logic: Fails loudly by raising exceptions when business rules are violated.
- 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
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.")
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
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-exceptfor logic thatif/elsecan 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)