Exception Hierarchy Pattern¶
Purpose¶
Exceptions control message flow in event-driven handlers. Instead of if/else chains, the exception type tells the handler how to dispose of the message.
The Hierarchy¶
Exception
├── RecoverableException → Requeue with exponential backoff
├── PermanentFailureException → Send to DLQ immediately, no retries
├── SilentSuccessException → Delete message, log as success
├── CheckpointUpdateError → Checkpoint persistence failed
├── DBTransactionError → Database operation failed (wraps original)
└── OperationalError (MySQL) → Visibility timeout + batch retry
├── Error 1213: Deadlock
└── Error 1205: Lock wait timeout
Implementation¶
# core/exceptions.py
class RecoverableException(Exception):
"""Transient failure — retry with backoff. Message requeued to SQS."""
def __init__(self, document_id: str, reason: str,
retry_count: int = 0, prev_page_count: int = 0):
self.document_id = document_id
self.reason = reason
self.retry_count = retry_count
self.prev_page_count = prev_page_count
super().__init__(f"Recoverable error for {document_id}: {reason}")
class PermanentFailureException(Exception):
"""Unrecoverable failure — skip retries, send directly to DLQ."""
def __init__(self, reason: str):
self.reason = reason
super().__init__(f"Permanent failure: {reason}")
class SilentSuccessException(Exception):
"""Not an error — processing should stop but message is successfully handled.
Example: document already processed, DLQ redrive final pass."""
def __init__(self, reason: str):
self.reason = reason
super().__init__(f"Silent success: {reason}")
class CheckpointUpdateError(Exception):
"""Checkpoint creation or update failed after retries."""
pass
class DBTransactionError(Exception):
"""Database operation failed. Wraps the original exception with context."""
def __init__(self, original_exception: Exception, context: str = ""):
self.original_exception = original_exception
self.context = context
super().__init__(f"DB error ({context}): {original_exception}")
Handler Routing¶
# handlers/index.py
def _handle_exception(e: Exception, record: dict, batch_item_failures: list):
message_id = record["messageId"]
if isinstance(e, PermanentFailureException):
log_message("error", f"Permanent failure: {e.reason}")
send_to_dlq(record) # Direct to DLQ, no retries
elif isinstance(e, RecoverableException):
log_message("warning", f"Recoverable: {e.reason}, retry #{e.retry_count}")
handle_requeue_exception(record, e) # Exponential backoff requeue
elif isinstance(e, SilentSuccessException):
log_message("info", f"Silent success: {e.reason}")
# Message deleted automatically — no failure reported
elif isinstance(e, OperationalError):
log_message("warning", f"MySQL operational error: {e}")
batch_item_failures.append({"itemIdentifier": message_id})
change_message_visibility(record, 120) # 2-minute cooldown
else:
log_message("error", f"Unexpected error: {e}", exc_info=True)
batch_item_failures.append({"itemIdentifier": message_id})
When to Use Each Type¶
| Exception | Use When | SQS Behavior |
|---|---|---|
RecoverableException |
Transient failure (race condition, temporary resource unavailable) | Requeue with exponential backoff |
PermanentFailureException |
Data is invalid, cannot ever succeed | Direct to DLQ |
SilentSuccessException |
Already processed, or intentional skip | Message deleted (success) |
DBTransactionError |
Database write failed | Depends on handler — usually batch retry |
OperationalError |
MySQL deadlock or lock timeout | Visibility timeout, batch retry |
Key Insight¶
The exception hierarchy is the control flow contract between core/ and handlers/.
Core code raises the appropriate exception type. The handler translates it into the
correct SQS disposition. This keeps business logic clean — core/ doesn't know about
SQS, DLQs, or visibility timeouts.
Ask the Architecture
×
Ask questions about Nextpoint architecture, patterns, rules, or any module. Powered by Claude Opus 4.6.