Error Handling
Stabilize provides a structured error hierarchy with semantic error codes, chain traversal, and automatic classification.
Error Hierarchy
All Stabilize exceptions inherit from a two-tier hierarchy:
StabilizeBaseException # Root - bypasses default handlers
└── StabilizeError # Standard errors - caught and handled
├── TransientError # Retryable (network, timeout, 5xx)
│ └── ConcurrencyError # Optimistic lock failures
├── PermanentError # Non-retryable (auth, validation, 4xx)
├── RecoveryError # Crash recovery failed
├── ConfigurationError # Invalid configuration
├── TaskError # Task execution failed
│ ├── TaskTimeoutError # Task exceeded timeout
│ └── TaskNotFoundError # Task implementation not found
├── WorkflowError # Workflow-level issues
│ └── WorkflowNotFoundError
├── StageError # Stage-level issues
├── QueueError # Queue operations failed
│ └── DeadLetterError # Message moved to DLQ
└── VerificationError # Stage verification failed
└── TransientVerificationError # Retry verification
Transient vs Permanent Errors
The distinction between transient and permanent errors determines retry behavior:
Transient Errors (will be retried):
from stabilize.errors import TransientError
# Network timeout - retry with backoff
raise TransientError(
"Connection timed out",
retry_after=30, # Hint: wait 30 seconds
context_update={"processed_items": 50} # Preserve progress
)
Permanent Errors (moved to Dead Letter Queue):
from stabilize.errors import PermanentError
# Validation failure - don't retry
raise PermanentError(
"Invalid input format",
code=400
)
Check error type programmatically:
from stabilize.errors import is_transient, is_permanent
try:
result = task.execute(stage)
except Exception as e:
if is_transient(e):
# Schedule retry with backoff
pass
elif is_permanent(e):
# Move to DLQ, alert operator
pass
Queue Messages That Cannot Be Decoded
A queue row whose type is unknown to this version, whose enum values do not
exist, or whose fields fail the message contract is never retried and never
raised to the poller. poll_one moves it to the dead letter queue with the
reason and returns None, and polling continues with the next message. The
payload is kept exactly as stored.
The DLQ entry’s error starts with Deserialization failed: followed by
the message type and the cause. What happens next is the operator’s decision:
for entry in queue.list_dlq():
print(entry["id"], entry["message_type"], entry["error"])
queue.replay_dlq(entry_id) # back onto the queue, attempts reset
During a rolling upgrade an older worker can meet a message type that only the newer version defines. That message is quarantined rather than lost; replay it once every worker runs the newer version.
Error Codes
Stabilize uses semantic ErrorCode values for programmatic error handling:
from stabilize.error_codes import ErrorCode
# Available error codes
ErrorCode.UNKNOWN # Unclassified error
ErrorCode.RESOURCE_EXHAUSTED # Memory, disk, quota exceeded
ErrorCode.TASK_TIMEOUT # Task exceeded timeout
ErrorCode.TASK_NOT_FOUND # Task implementation missing
ErrorCode.UPSTREAM_DEPENDENCY_FAILED # Upstream stage failed
ErrorCode.CONFIGURATION_INVALID # Bad configuration
ErrorCode.CONCURRENCY_CONFLICT # Optimistic lock failure
ErrorCode.AUTHENTICATION_FAILED # Auth credentials invalid
ErrorCode.VALIDATION_FAILED # Input validation failed
ErrorCode.NETWORK_ERROR # Network/connection issues
ErrorCode.CIRCUIT_OPEN # Circuit breaker tripped
ErrorCode.BULKHEAD_FULL # Resource pool exhausted
ErrorCode.VERIFICATION_FAILED # Stage verification failed
ErrorCode.USER_CODE_ERROR # User task raised exception
ErrorCode.SYSTEM_ERROR # Internal system error
ErrorCode.RECOVERY_FAILED # Crash recovery failed
Each exception carries its error code:
from stabilize.errors import TaskTimeoutError
try:
execute_task(stage)
except TaskTimeoutError as e:
print(e.error_code) # ErrorCode.TASK_TIMEOUT
print(e.error_code.value) # "TASK_TIMEOUT"
Override the default error code:
raise TransientError(
"Rate limited by API",
error_code=ErrorCode.RESOURCE_EXHAUSTED
)
Error Chain Traversal
When errors are wrapped (e.g., by bulkheads or retries), use chain traversal to find the root cause:
from stabilize.error_codes import error_chain, find_in_chain, classify_error
try:
execute_with_resilience(...)
except Exception as e:
# Get full chain from root to leaf
chain = error_chain(e)
for err in chain:
print(f" - {type(err).__name__}: {err}")
# Find specific error type in chain
timeout = find_in_chain(e, TaskTimeoutError)
if timeout:
print(f"Task {timeout.task_name} timed out")
# Auto-classify any exception
code = classify_error(e) # Returns ErrorCode
Example chain:
BulkheadError: Execution failed
- RuntimeError: Task execution failed
- TaskTimeoutError: Shell command timed out after 60s
Error Truncation
Large error messages are automatically truncated before storage to prevent database bloat:
from stabilize.errors import truncate_error
# Truncate to 100KB with UTF-8 aware boundary handling
safe_message = truncate_error(large_error_message)
# Customize max size
safe_message = truncate_error(message, max_bytes=50_000)
# Result includes marker when truncated
# "Error details here... [TRUNCATED]"
The truncation:
Respects UTF-8 character boundaries
Appends
[TRUNCATED]marker when shortenedDefault limit is 100KB (configurable)
Custom Task Errors
Create rich error context for debugging:
from stabilize.errors import TaskError
from stabilize.error_codes import ErrorCode
raise TaskError(
"Failed to process batch",
task_name="batch_processor",
stage_id=stage.id,
execution_id=stage.execution_id,
error_code=ErrorCode.USER_CODE_ERROR,
details={
"batch_id": "batch_123",
"failed_item": 42,
"reason": "Invalid format"
}
)
Error Classification
The classify_error function maps any exception to an ErrorCode:
from stabilize.error_codes import classify_error
# Maps based on exception type and name patterns
classify_error(TimeoutError(...)) # TASK_TIMEOUT
classify_error(ConnectionError(...)) # NETWORK_ERROR
classify_error(PermissionError(...)) # AUTHENTICATION_FAILED
classify_error(ValueError(...)) # VALIDATION_FAILED
Classification rules (in order):
Check if it’s a Stabilize exception with explicit
error_codeCheck exception type name for patterns (timeout, connection, auth, etc.)
Fall back to
ErrorCode.UNKNOWN
Best Practices
Always use the right error type:
# Good: Semantic error type raise TransientError("API rate limited", retry_after=60) # Bad: Generic exception raise Exception("API rate limited")
Include context in errors:
raise TaskError( "Failed to fetch user", details={"user_id": user_id, "api_response": response.status_code} )
Preserve progress across retries:
raise TransientError( "Batch processing interrupted", context_update={"last_processed_index": i} )
Use error codes for routing:
if e.error_code == ErrorCode.AUTHENTICATION_FAILED: alert_security_team(e) elif e.error_code == ErrorCode.RESOURCE_EXHAUSTED: scale_up_resources()
Key Files
src/stabilize/errors.py- Exception hierarchy and utilitiessrc/stabilize/error_codes.py- ErrorCode enum and classificationsrc/stabilize/handlers/run_task/error.py- Error handling in task execution
Errors During Synchronous Draining
QueueProcessor.process_one() and process_all() handle messages on the
calling thread. When a handler raises, the message is rescheduled by
config.retry_delay (15 s by default) and the exception is re-raised to the
caller; process_all() stops at that point. The message is delivered again by
a later call once the delay has passed. processor.start() runs the same
handling in the background and logs the error instead of raising it.
A task that completed while the store was unavailable (for example a statement timeout or a pool timeout while saving its result) is not executed again: its result is held and saved by the redelivery.
To drain through transient errors in a script or a test:
processor.config.retry_delay = timedelta(milliseconds=50)
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
try:
processor.process_all(timeout=1.0)
except Exception:
logger.warning("handler error; the message was rescheduled", exc_info=True)
if store.retrieve(workflow.id).status.is_complete:
break