Reference
Error Handling Policy
Error Handling Policy
Standardized error handling guidelines for the eIOU codebase.
Table of Contents
- Overview
- Core Principles
- Exception Hierarchy
- When to Use Each Exception Type
- Required Context
- Integration with ErrorCodes
- Entry Point Handling
- Code Examples
- Testing Exceptions
Overview
This document defines the standardized approach to error handling across the eIOU codebase. The goal is to provide:
- Consistency: All errors are handled the same way across CLI, API, and service layers
- Rich Context: Every error includes actionable information for debugging and user feedback
- Testability: Exception-based errors can be caught and verified in unit tests
- Recoverability: Clear distinction between fatal and recoverable errors enables proper retry logic
Core Principles
1. Always Throw Exceptions, Never Return Error Codes
// WRONG - Returns false for errors
public function findContact(string $identifier): array|false
{
$contact = $this->contactRepo->lookupByName($identifier);
if (!$contact) {
return false; // Caller doesn't know WHY it failed
}
return $contact;
}
// CORRECT - Throws exception with context
public function findContact(string $identifier): array
{
$contact = $this->contactRepo->lookupByName($identifier);
if (!$contact) {
throw new RecoverableServiceException(
"Contact not found: $identifier",
ErrorCodes::CONTACT_NOT_FOUND,
['identifier' => $identifier]
);
}
return $contact;
}
2. Use Appropriate Exception Type Based on Recoverability
Choose the exception type based on whether the error condition can potentially be resolved:
| Exception Type | Recoverable? | Exit Code | Use When |
|---|---|---|---|
FatalServiceException |
No | 1 | System failures, security issues, missing config |
RecoverableServiceException |
Yes | 0 (configurable) | Network issues, temporary failures, lock conflicts |
ValidationServiceException |
No | 1 | Invalid user input, missing required fields |
3. Include Context with Every Exception
Every exception should include enough context to understand and debug the error:
throw new FatalServiceException(
"Failed to restore wallet from seed phrase",
ErrorCodes::SEED_RESTORE_FAILED,
[
'word_count' => count($words),
'expected_count' => 24,
'reason' => 'Invalid checksum'
]
);
Exception Hierarchy
All service exceptions extend from the abstract ServiceException base class.
Exception (PHP built-in)
|
+-- ServiceException (abstract)
|
+-- FatalServiceException
|
+-- RecoverableServiceException
|
+-- ValidationServiceException
ServiceException (Abstract Base)
Location: files/src/exceptions/ServiceException.php
The base class provides common functionality for all service exceptions:
abstract class ServiceException extends Exception
{
protected string $errorCode; // ErrorCodes constant
protected int $httpStatus; // HTTP status for API responses
protected array $context; // Additional debugging data
public function getErrorCode(): string;
public function getHttpStatus(): int;
public function getContext(): array;
abstract public function getExitCode(): int;
public function toArray(): array;
public function toJson(): string;
}
FatalServiceException
Location: files/src/exceptions/FatalServiceException.php
For unrecoverable errors that should terminate the operation immediately.
Characteristics:
- Always returns exit code 1
- Default error code:
ErrorCodes::INTERNAL_ERROR - Should be logged at ERROR level
Examples of fatal errors:
- Missing wallet when one is required
- Corrupted database or data integrity failures
- Security violations or unauthorized access attempts
- Missing required configuration
- System-level failures (filesystem, memory)
RecoverableServiceException
Location: files/src/exceptions/RecoverableServiceException.php
For errors that might succeed on retry or with user intervention.
Characteristics:
- Configurable exit code (default 0)
- Default error code:
ErrorCodes::GENERAL_ERROR - Should be logged at INFO or WARNING level
Examples of recoverable errors:
- Network timeouts or connection failures
- Temporary service unavailability
- Rate limiting
- Lock conflicts (resource in use)
- Contact unreachable
ValidationServiceException
Location: files/src/exceptions/ValidationServiceException.php
For input validation errors with field-specific information.
Characteristics:
- Always returns exit code 1
- Default error code:
ErrorCodes::VALIDATION_ERROR - Includes optional
fieldproperty for specific field errors - Should be logged at WARNING level
Examples of validation errors:
- Invalid address format
- Invalid name (too long, invalid characters)
- Missing required fields
- Invalid amount or currency
- Parameter out of range
When to Use Each Exception Type
FatalServiceException
Use for errors where:
- The operation cannot possibly succeed without system changes
- Security has been compromised
- Data integrity is at risk
- Required resources are missing
// System configuration missing
throw new FatalServiceException(
"Database configuration not found",
ErrorCodes::DB_CONFIG_NOT_FOUND,
['config_path' => '/etc/eiou/config/db.conf']
);
// Security violation
throw new FatalServiceException(
"Unauthorized access attempt detected",
ErrorCodes::PERMISSION_DENIED,
['attempted_action' => 'admin_operation', 'user_key' => $publicKeyHash]
);
// Missing required resource
throw new FatalServiceException(
"Wallet not found - generate a wallet first",
ErrorCodes::WALLET_NOT_FOUND
);
// Data integrity failure
throw new FatalServiceException(
"Transaction chain integrity check failed",
ErrorCodes::CHAIN_INTEGRITY_FAILED,
['txid' => $transactionId, 'expected_hash' => $expected, 'actual_hash' => $actual]
);
RecoverableServiceException
Use for errors where:
- A retry might succeed
- External conditions might change
- User can take action to resolve
// Network failure
throw new RecoverableServiceException(
"Failed to connect to contact",
ErrorCodes::CONNECTION_FAILED,
['address' => $contactAddress, 'timeout' => 30]
);
// Temporary unavailability
throw new RecoverableServiceException(
"Contact is currently offline",
ErrorCodes::CONTACT_UNREACHABLE,
['contact_name' => $name, 'last_seen' => $lastOnline]
);
// Rate limiting
throw new RecoverableServiceException(
"Rate limit exceeded, try again later",
ErrorCodes::RATE_LIMIT_EXCEEDED,
['retry_after' => $retrySeconds],
429 // HTTP status
);
// Resource lock conflict
throw new RecoverableServiceException(
"Transaction already in progress",
ErrorCodes::TRANSACTION_IN_PROGRESS,
['contact' => $contactName]
);
ValidationServiceException
Use for errors where:
- User input is invalid
- Required fields are missing
- Values are out of acceptable range
// Invalid format
throw new ValidationServiceException(
"Invalid address format",
ErrorCodes::INVALID_ADDRESS,
'address', // field name
400, // HTTP status
['provided' => $address, 'expected_format' => 'hostname.onion or https://domain']
);
// Missing required field
throw new ValidationServiceException(
"Contact name is required",
ErrorCodes::MISSING_ARGUMENT,
'name'
);
// Value out of range
throw new ValidationServiceException(
"Fee percent must be between 0 and 100",
ErrorCodes::INVALID_FEE,
'fee_percent',
400,
['provided' => $feePercent, 'min' => 0, 'max' => 100]
);
// Invalid combination
throw new ValidationServiceException(
"Cannot send transaction to yourself",
ErrorCodes::SELF_SEND,
'recipient'
);
Required Context
Minimum Required Fields
Every exception must include:
- Message: Human-readable error description
- Error Code: Constant from
ErrorCodesclass - HTTP Status: Auto-detected from error code (can override)
Recommended Context Data
Include relevant context to aid debugging:
| Error Type | Recommended Context |
|---|---|
| Contact errors | identifier, contact_name, pubkey_hash |
| Transaction errors | txid, amount, currency, recipient |
| Validation errors | field, provided_value, expected_format |
| Network errors | address, timeout, retry_after |
| Auth errors | key_id, permission, resource |
Context Guidelines
- Do include relevant identifiers and values
- Do include expected vs actual when applicable
- Do NOT include sensitive data (private keys, passwords, full seeds)
- Do NOT include excessively large data (full request bodies)
Integration with ErrorCodes
The ErrorCodes class (files/src/core/ErrorCodes.php) provides:
- Standardized error code constants
- Automatic HTTP status mapping
- Human-readable error titles
Using Error Codes
use Eiou\Core\ErrorCodes;
// Get HTTP status for an error code
$httpStatus = ErrorCodes::getHttpStatus(ErrorCodes::CONTACT_NOT_FOUND); // Returns 404
// Get human-readable title
$title = ErrorCodes::getTitle(ErrorCodes::INSUFFICIENT_FUNDS); // Returns "Insufficient Funds"
// Check if code is valid
$isValid = ErrorCodes::isValid('CUSTOM_ERROR'); // Returns false
Error Code Categories
| Prefix | Category | HTTP Range |
|---|---|---|
AUTH_* |
Authentication/Authorization | 401, 403 |
CONTACT_* |
Contact operations | 400, 403, 404, 409 |
TRANSACTION_* |
Transaction operations | 400, 403, 429, 500 |
INVALID_*, MISSING_* |
Validation | 400 |
*_NOT_FOUND |
Resource not found | 404 |
*_FAILED |
Operation failures | 500 |
*_EXISTS |
Conflict | 409 |
Adding New Error Codes
When adding new error codes:
- Add the constant to
ErrorCodes.php - Add HTTP status mapping in
getHttpStatus() - Add human-readable title in
getTitle()
// In ErrorCodes.php
// Add constant
public const CUSTOM_ERROR = 'CUSTOM_ERROR';
// Add to getHttpStatus() mapping
self::CUSTOM_ERROR => 400,
// Add to getTitle() mapping
self::CUSTOM_ERROR => 'Custom Error Title',
Entry Point Handling
CLI Entry Point (Eiou.php)
The CLI entry point (files/root/cli/Eiou.php) wraps all command dispatch in a try-catch block:
try {
// Command dispatch logic...
} catch (ValidationServiceException $e) {
// Handle validation errors
$output->error($e->getMessage(), $e->getErrorCode(), $e->getHttpStatus(), $e->getContext());
$app->getLogger()->warning("Validation error", [
'command' => $request,
'field' => $e->getField(),
'error' => $e->getMessage(),
'code' => $e->getErrorCode()
]);
exit($e->getExitCode());
} catch (FatalServiceException $e) {
// Handle fatal errors
$output->error($e->getMessage(), $e->getErrorCode(), $e->getHttpStatus(), $e->getContext());
$app->getLogger()->error("Fatal service error", [
'command' => $request,
'error' => $e->getMessage(),
'code' => $e->getErrorCode(),
'context' => $e->getContext()
]);
exit($e->getExitCode());
} catch (RecoverableServiceException $e) {
// Handle recoverable errors
$output->error($e->getMessage(), $e->getErrorCode(), $e->getHttpStatus(), $e->getContext());
$app->getLogger()->info("Recoverable service error", [
'command' => $request,
'error' => $e->getMessage(),
'code' => $e->getErrorCode()
]);
exit($e->getExitCode());
} catch (ServiceException $e) {
// Catch-all for any ServiceException subclass
$output->error($e->getMessage(), $e->getErrorCode(), $e->getHttpStatus(), $e->getContext());
$app->getLogger()->error("Service error", [
'command' => $request,
'error' => $e->getMessage(),
'code' => $e->getErrorCode()
]);
exit($e->getExitCode());
}
API Entry Point (ApiController.php)
The API controller (files/src/api/ApiController.php) catches exceptions and returns JSON error responses:
try {
$response = match ($resource) {
'wallet' => $this->handleWallet($method, $action, $params, $body),
'contacts' => $this->handleContacts($method, $action, $id, $params, $body),
// ...
};
} catch (ServiceException $e) {
// Handle ServiceExceptions with their rich error context
$this->log('warning', 'Service exception in API request', [
'path' => $path,
'error' => $e->getMessage(),
'code' => $e->getErrorCode(),
'context' => $e->getContext()
]);
$response = $this->errorResponse(
$e->getMessage(),
$e->getHttpStatus(),
strtolower($e->getErrorCode())
);
} catch (Exception $e) {
// Generic fallback for unexpected exceptions
$this->log('error', 'API request failed', [
'path' => $path,
'error' => $e->getMessage()
]);
$response = $this->errorResponse('Internal server error', 500, 'internal_error');
}
Code Examples
Complete Service Method Example
use Eiou\Core\ErrorCodes;
use Eiou\Exceptions\FatalServiceException;
use Eiou\Exceptions\RecoverableServiceException;
use Eiou\Exceptions\ValidationServiceException;
class ContactService
{
/**
* Block a contact
*
* @param string|null $identifier Contact name or address
* @param CliOutputManager $output Output manager
* @return bool True on success
* @throws ValidationServiceException If identifier is missing or invalid
* @throws RecoverableServiceException If contact not found
* @throws FatalServiceException If database operation fails
*/
public function blockContact(?string $identifier, CliOutputManager $output): bool
{
// Validate input
if (empty($identifier)) {
throw new ValidationServiceException(
"Contact name or address required",
ErrorCodes::MISSING_ARGUMENT,
'identifier'
);
}
// Find contact
$contact = $this->findContact($identifier);
if (!$contact) {
throw new RecoverableServiceException(
"Contact not found: $identifier",
ErrorCodes::CONTACT_NOT_FOUND,
['identifier' => $identifier]
);
}
// Check if already blocked
if ($contact['status'] === Constants::CONTACT_STATUS_BLOCKED) {
throw new ValidationServiceException(
"Contact is already blocked",
ErrorCodes::CONTACT_BLOCKED,
'status',
400,
['contact_name' => $contact['name'], 'current_status' => $contact['status']]
);
}
// Attempt to block
try {
$success = $this->contactRepo->updateContactStatus(
$contact['pubkey'],
Constants::CONTACT_STATUS_BLOCKED
);
} catch (Exception $e) {
throw new FatalServiceException(
"Database error while blocking contact",
ErrorCodes::BLOCK_FAILED,
['contact_name' => $contact['name'], 'db_error' => $e->getMessage()],
500,
$e // Chain the original exception
);
}
if (!$success) {
throw new FatalServiceException(
"Failed to block contact",
ErrorCodes::BLOCK_FAILED,
['contact_name' => $contact['name']]
);
}
$output->success(
"Contact '{$contact['name']}' has been blocked",
['contact_name' => $contact['name'], 'status' => 'blocked']
);
return true;
}
}
Exception Chaining
When catching lower-level exceptions, chain them for full stack trace:
try {
$result = $this->networkClient->send($data);
} catch (NetworkException $e) {
throw new RecoverableServiceException(
"Failed to send transaction: " . $e->getMessage(),
ErrorCodes::NETWORK_ERROR,
['address' => $address, 'timeout' => $timeout],
503,
0, // Exit code
$e // Previous exception - enables full stack trace
);
}
JSON Output
Exceptions can be serialized for JSON APIs:
try {
// ... operation
} catch (ServiceException $e) {
// toArray() returns structured error data
$errorData = $e->toArray();
// {
// "success": false,
// "error": {
// "code": "CONTACT_NOT_FOUND",
// "message": "Contact not found: alice",
// "title": "Contact Not Found",
// "httpStatus": 404,
// "context": {"identifier": "alice"}
// }
// }
// toJson() returns JSON string
echo $e->toJson();
}
Testing Exceptions
Unit Test Example
use PHPUnit\Framework\TestCase;
use Eiou\Exceptions\ValidationServiceException;
use Eiou\Exceptions\RecoverableServiceException;
use Eiou\Core\ErrorCodes;
class ContactServiceTest extends TestCase
{
public function testBlockContactThrowsOnMissingIdentifier(): void
{
$this->expectException(ValidationServiceException::class);
$this->expectExceptionMessage("Contact name or address required");
$service = new ContactService($this->mockRepo, $this->mockOutput);
$service->blockContact(null, $this->mockOutput);
}
public function testBlockContactThrowsOnNotFound(): void
{
$this->mockRepo->method('lookupByName')->willReturn(null);
try {
$service = new ContactService($this->mockRepo, $this->mockOutput);
$service->blockContact('unknown', $this->mockOutput);
$this->fail('Expected RecoverableServiceException');
} catch (RecoverableServiceException $e) {
$this->assertEquals(ErrorCodes::CONTACT_NOT_FOUND, $e->getErrorCode());
$this->assertEquals(404, $e->getHttpStatus());
$this->assertArrayHasKey('identifier', $e->getContext());
}
}
public function testExceptionContext(): void
{
try {
throw new ValidationServiceException(
"Invalid fee",
ErrorCodes::INVALID_FEE,
'fee_percent',
400,
['provided' => 150, 'max' => 100]
);
} catch (ValidationServiceException $e) {
$context = $e->getContext();
$this->assertEquals('fee_percent', $e->getField());
$this->assertEquals(150, $context['provided']);
$this->assertEquals(100, $context['max']);
}
}
}
Integration Test Example
#!/bin/bash
# Test exception handling in CLI
# Test validation error
result=$(docker exec alice php /app/eiou/Eiou.php block --json 2>&1)
echo "$result" | jq -e '.success == false' || exit 1
echo "$result" | jq -e '.error.code == "MISSING_ARGUMENT"' || exit 1
# Test contact not found
result=$(docker exec alice php /app/eiou/Eiou.php block nonexistent --json 2>&1)
echo "$result" | jq -e '.error.code == "CONTACT_NOT_FOUND"' || exit 1
echo "All exception tests passed"
Related Documentation
- /docs/reference/error-codes - Complete list of error codes and HTTP mappings
- /docs/reference/api-reference - API error response formats
- /docs/reference/cli-reference - CLI error output formats
- /docs/reference/architecture - System architecture and service layer design