Centralized AI Error Handling
Centralized Error Handling and Custom Exceptions
Unit 12, Lesson 1 covered the OpenAI API's error codes — what a rate-limit error looks like, what an authentication error looks like, what a server-side error looks like. That lesson was about recognizing errors. This lesson is about handling them consistently across an entire application, using Python's exception system and a small hierarchy of custom exception types, so that error handling logic is not duplicated (or forgotten) at every call site.
The Problem with Catching SDK Exceptions Everywhere
A natural first approach is to catch the SDK's own exception types directly, wherever a call is made:
from openai import APIError, RateLimitError
def summarize(client, text: str) -> str:
try:
response = client.responses.create(
model="gpt-5.6-terra",
input=f"Summarize:\n\n{text}",
)
return response.output_text
except RateLimitError:
print("Rate limited, please try again later.")
return ""
except APIError:
print("Something went wrong calling the API.")
return ""
If this pattern is repeated in every function that calls the SDK, two problems emerge. First, every call site needs to import SDK-specific exception types and know how to react to each one, which duplicates the same handling logic dozens of times. Second, the rest of the application (everything that calls summarize) now has to guess whether an empty string means "the model produced no output" or "an error was silently swallowed" — the original error information is lost.
Defining an Application-Specific Exception Hierarchy
A better approach is to translate SDK-level exceptions into a small set of application-specific exceptions, defined once, that describe what went wrong in terms meaningful to your own application rather than the SDK's internals:
class AIServiceError(Exception):
"""Base class for all errors raised by this application's AI integrations."""
class AIRateLimitedError(AIServiceError):
"""Raised when the AI provider is rate-limiting requests."""
class AITransientError(AIServiceError):
"""Raised for errors that are likely temporary and might succeed on retry."""
class AIInvalidRequestError(AIServiceError):
"""Raised when the request itself was malformed or otherwise permanently invalid."""
Each of these inherits from AIServiceError, which itself inherits from the built-in Exception. This is a deliberate hierarchy: code that wants to catch any AI-related error can catch AIServiceError, while code that needs to react differently to a rate limit versus a permanently invalid request can catch the more specific subclasses. Defining the hierarchy in one place means every part of the application shares the same vocabulary for AI-related failures.
Translating SDK Exceptions at the Boundary
The service class (Lesson 1) is the natural place to catch SDK-specific exceptions and translate them into the application's own exception types, exactly once:
from openai import APIError, APITimeoutError, RateLimitError
class SummarizerService:
def __init__(self, client, model: str = "gpt-5.6-terra") -> None:
self._client = client
self._model = model
def summarize(self, text: str) -> str:
try:
response = self._client.responses.create(
model=self._model,
input=f"Summarize:\n\n{text}",
)
return response.output_text
except RateLimitError as error:
raise AIRateLimitedError("The AI provider is rate-limiting requests.") from error
except APITimeoutError as error:
raise AITransientError("The request to the AI provider timed out.") from error
except APIError as error:
raise AIServiceError(f"The AI provider returned an error: {error}") from error
Everything outside this service class now only ever needs to know about AIRateLimitedError, AITransientError, AIInvalidRequestError, and AIServiceError — never about openai.RateLimitError or any other SDK-specific type. If the SDK's exception types change in a future version, only this one translation point needs to be updated.
Note: The exact exception class names exported by the
openaipackage (APIError,RateLimitError,APITimeoutError, and others) can change between SDK versions. Confirm the current exception hierarchy against the installed version's documentation before writing translation code like this against it.
Why raise ... from error Matters
The from error clause at the end of each raise statement preserves the exception chain — Python keeps a reference to the original exception (error) as the __cause__ of the new one, and prints both when the exception is unhandled:
openai.RateLimitError: Rate limit exceeded
The above exception was the direct cause of the following exception:
AIRateLimitedError: The AI provider is rate-limiting requests.
Omitting from error (writing just raise AIRateLimitedError(...)) still raises the new exception correctly, but discards the connection to the original SDK error, making debugging significantly harder when the translated message alone is not enough to diagnose the underlying cause.
Handling Application Exceptions at the Call Site
With translation centralized in the service class, calling code can react to specific, meaningful exception types without ever touching the SDK's own error types:
def handle_summarize_request(service: SummarizerService, text: str) -> str:
try:
return service.summarize(text)
except AIRateLimitedError:
return "The service is busy right now. Please try again in a moment."
except AITransientError:
return "A temporary issue occurred. Please try again."
except AIServiceError:
return "Something went wrong generating a summary."
Each except clause here reflects a decision about user-facing behavior, not about SDK internals — exactly the separation of concerns centralized error handling is meant to produce.
Combining Custom Exceptions with the Retry Decorator
Lesson 5 introduced a generic retry decorator that caught any Exception. With a proper exception hierarchy in place, the retry decorator can be made more precise, retrying only on errors that are actually worth retrying:
import functools
import time
def retry_on_transient(max_attempts: int = 3, delay_seconds: float = 1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except (AIRateLimitedError, AITransientError) as error:
last_error = error
if attempt < max_attempts:
time.sleep(delay_seconds)
except AIServiceError:
raise # not transient — retrying will not help, fail immediately
raise last_error
return wrapper
return decorator
This version only retries AIRateLimitedError and AITransientError, and immediately re-raises any other AIServiceError (such as AIInvalidRequestError) without wasting time retrying a request that will never succeed. This is only possible because the exception hierarchy distinguishes transient failures from permanent ones — a bare except Exception cannot make that distinction.
Testing Error Translation and Handling
Because the service class raises application-specific exceptions, tests can verify error-handling behavior using a fake client that raises SDK-like exceptions, without needing the real openai exception types at all:
class RateLimitedFakeResponsesAPI:
def create(self, **kwargs):
raise RateLimitError("simulated rate limit")
class RateLimitedFakeClient:
def __init__(self) -> None:
self.responses = RateLimitedFakeResponsesAPI()
def test_summarize_translates_rate_limit_into_application_exception() -> None:
service = SummarizerService(client=RateLimitedFakeClient())
try:
service.summarize("some text")
raised = False
except AIRateLimitedError:
raised = True
assert raised
print("PASS: RateLimitError is translated into AIRateLimitedError")
test_summarize_translates_rate_limit_into_application_exception()
This test uses a real RateLimitError from the SDK (or a stand-in with the same name, in an environment where importing it directly is inconvenient) attached to a fake client, confirming that the service class's translation logic actually converts it into the expected application exception — the behavior that matters to the rest of the codebase.
Common Mistakes
Catching Exception broadly and swallowing it silently. A bare except Exception: return None hides real problems (including bugs in your own code, not just SDK errors) and makes failures invisible until a user reports missing or wrong output.
Re-raising the SDK's own exception types throughout the codebase. If callers throughout the application need to import and catch openai.RateLimitError directly, the codebase has a hard dependency on SDK internals everywhere, defeating the purpose of centralizing error handling in one place.
Losing the original exception when translating. Raising a new exception without from error discards the traceback and message of the original failure, which is often essential information when debugging an issue that only reproduces in production.
Best Practices
Define one small, purposeful exception hierarchy per application (or per major subsystem). A base exception plus a handful of meaningful subclasses is far more useful than either one generic exception for everything or dozens of overly specific ones.
Translate SDK-specific exceptions to application exceptions at the boundary — in the service class, not scattered throughout business logic. This keeps the translation logic in one place and lets the rest of the application depend only on your own exception types.
Always use raise ... from error when translating one exception into another. This preserves the full chain of causation, which is invaluable when diagnosing an issue after the fact from logs or an error-tracking tool.
Distinguish transient from permanent errors in the exception hierarchy itself. This lets retry logic (Lesson 5) make correct decisions about what to retry, based on the exception type rather than string-matching error messages.