Error Handling
Contents
Error Handling#
This guide is for developers implementing robust error handling for OpenSPP API V2 integrations.
Error Response Format#
All errors return an OperationOutcome resource:
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "invalid",
"details": {
"coding": [
{
"system": "urn:openspp:error",
"code": "INVALID_IDENTIFIER",
"display": "Invalid identifier format"
}
],
"text": "Identifier value contains invalid characters"
},
"diagnostics": "identifier[0].value: expected pattern ^[A-Z0-9-]+$",
"location": ["Individual.identifier[0].value"]
}
]
}
Field Reference#
Field |
Description |
|---|---|
|
|
|
FHIR issue type code |
|
Structured error code |
|
Human-readable error message |
|
Technical details for debugging |
|
JSON path to the problematic field |
HTTP Status Codes#
Code |
Meaning |
Description |
|---|---|---|
200 |
OK |
Successful read/update |
201 |
Created |
Successful create |
400 |
Bad Request |
Invalid request format |
401 |
Unauthorized |
Missing/invalid token |
403 |
Forbidden |
Insufficient permissions/consent |
404 |
Not Found |
Resource doesn't exist |
409 |
Conflict |
Version conflict (optimistic locking) |
422 |
Unprocessable Entity |
Validation error |
429 |
Too Many Requests |
Rate limit exceeded |
500 |
Internal Server Error |
Server error |
503 |
Service Unavailable |
Server temporarily unavailable |
Common Errors#
400 Bad Request#
Invalid request syntax or structure:
HTTP/1.1 400 Bad Request
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "invalid",
"details": {
"text": "Invalid JSON: Unexpected token at line 5"
}
}
]
}
Causes:
Malformed JSON
Invalid parameter names
Missing required headers
Solution:
# ✅ Good - Validate JSON before sending
import json
try:
data = json.dumps(resource)
response = requests.post(url, data=data, headers=headers)
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}")
401 Unauthorized#
Missing or invalid access token:
HTTP/1.1 401 Unauthorized
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "unauthorized",
"details": {
"text": "Invalid or expired access token"
}
}
]
}
Causes:
Token expired (tokens last 1 hour)
Token not provided
Invalid token format
Solution:
def api_request_with_token_refresh(url, token_manager):
"""Make request with automatic token refresh."""
token = token_manager.get_token()
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
if response.status_code == 401:
# Token expired, refresh and retry
token_manager.refresh_token()
token = token_manager.get_token()
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json()
403 Forbidden#
Insufficient permissions or consent:
HTTP/1.1 403 Forbidden
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "forbidden",
"details": {
"coding": [
{
"system": "urn:openspp:error",
"code": "CONSENT_REQUIRED"
}
],
"text": "No active consent for this data access"
}
}
]
}
Error Codes:
Code |
Description |
|---|---|
|
No consent from registrant |
|
Consent has expired |
|
API client lacks required scope |
Solution:
def handle_forbidden_error(response):
"""Handle 403 Forbidden errors."""
outcome = response.json()
issue = outcome["issue"][0]
error_code = issue["details"]["coding"][0]["code"]
if error_code == "CONSENT_REQUIRED":
print("No consent on file. Contact registrant for consent.")
# Initiate consent request workflow
elif error_code == "CONSENT_EXPIRED":
print("Consent expired. Request renewal.")
# Trigger consent renewal
elif error_code == "SCOPE_INSUFFICIENT":
print("Insufficient scope. Contact administrator.")
# Log scope issue for admin review
404 Not Found#
Resource doesn't exist:
HTTP/1.1 404 Not Found
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "not-found",
"details": {
"coding": [
{
"system": "urn:openspp:error",
"code": "RESOURCE_NOT_FOUND"
}
],
"text": "Individual with identifier urn:gov:ph:psa:national-id|PH-999 not found"
}
}
]
}
Causes:
Wrong identifier
Resource was deleted
Typo in URL
Solution:
def get_individual_safe(identifier, token, base_url):
"""Fetch individual with 404 handling."""
try:
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(
f"{base_url}/Individual/{identifier}",
headers=headers
)
response.raise_for_status()
return response.json()
except requests.HTTPError as e:
if e.response.status_code == 404:
print(f"Individual not found: {identifier}")
return None
raise
409 Conflict#
Version conflict during update:
HTTP/1.1 409 Conflict
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "conflict",
"details": {
"coding": [
{
"system": "urn:openspp:error",
"code": "VERSION_CONFLICT"
}
],
"text": "Resource version mismatch. Expected: 3, Current: 5"
}
}
]
}
Cause: Another client modified the resource between your read and update.
Solution:
def update_with_conflict_resolution(identifier, updates, token, base_url, max_retries=3):
"""Update with automatic conflict resolution."""
for attempt in range(max_retries):
try:
# Fetch latest version
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(
f"{base_url}/Individual/{identifier}",
headers=headers
)
response.raise_for_status()
current = response.json()
# Merge updates
current.update(updates)
# Attempt update with current version
version = current["meta"]["versionId"]
headers["If-Match"] = f'"{version}"'
headers["Content-Type"] = "application/json"
response = requests.put(
f"{base_url}/Individual/{identifier}",
headers=headers,
json=current
)
response.raise_for_status()
return response.json()
except requests.HTTPError as e:
if e.response.status_code == 409:
if attempt < max_retries - 1:
print(f"Conflict detected, retrying ({attempt + 1}/{max_retries})...")
continue
raise
raise Exception("Update failed after max retries")
422 Unprocessable Entity#
Validation error:
HTTP/1.1 422 Unprocessable Entity
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "invalid",
"details": {
"coding": [
{
"system": "urn:openspp:error",
"code": "VALIDATION_ERROR"
}
],
"text": "Validation failed"
},
"diagnostics": "birthDate: date must be in the past",
"location": ["Individual.birthDate"]
},
{
"severity": "error",
"code": "required",
"details": {
"text": "Missing required field"
},
"diagnostics": "name: field is required",
"location": ["Individual.name"]
}
]
}
Common Validation Errors:
Code |
Description |
|---|---|
|
General validation failure |
|
Identifier format invalid |
|
Identifier already exists |
|
Referenced resource doesn't exist |
Solution:
def handle_validation_errors(response):
"""Parse and handle validation errors."""
outcome = response.json()
errors = []
for issue in outcome["issue"]:
location = issue.get("location", ["unknown"])[0]
message = issue["details"]["text"]
diagnostics = issue.get("diagnostics", "")
errors.append({
"field": location,
"message": message,
"details": diagnostics
})
return errors
# Usage
try:
response = requests.post(url, headers=headers, json=data)
response.raise_for_status()
except requests.HTTPError as e:
if e.response.status_code == 422:
errors = handle_validation_errors(e.response)
print("Validation errors:")
for error in errors:
print(f" {error['field']}: {error['message']}")
if error['details']:
print(f" {error['details']}")
429 Too Many Requests#
Rate limit exceeded:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 60
Retry-After: 60
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "throttled",
"details": {
"coding": [
{
"system": "urn:openspp:error",
"code": "RATE_LIMIT_EXCEEDED"
}
],
"text": "Rate limit exceeded. Retry after 60 seconds."
}
}
]
}
Rate Limit Headers:
Header |
Description |
|---|---|
|
Requests allowed per period |
|
Requests remaining |
|
Seconds until limit resets |
|
Seconds to wait before retry |
Solution:
import time
def api_request_with_rate_limit_handling(url, headers, max_retries=3):
"""Make request with rate limit handling."""
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
print(f"Rate limited. Waiting {retry_after} seconds...")
time.sleep(retry_after)
continue
response.raise_for_status()
return response.json()
raise Exception("Request failed after max retries")
500 Internal Server Error#
Server-side error:
HTTP/1.1 500 Internal Server Error
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "exception",
"details": {
"text": "Internal server error. Please contact support."
},
"diagnostics": "Error ID: abc123-def456"
}
]
}
Solution:
def api_request_with_retry(url, headers, max_retries=3):
"""Make request with exponential backoff."""
import time
for attempt in range(max_retries):
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json()
except requests.HTTPError as e:
if e.response.status_code == 500:
if attempt < max_retries - 1:
wait_time = 2 ** attempt # Exponential backoff
print(f"Server error, retrying in {wait_time}s...")
time.sleep(wait_time)
continue
raise
raise Exception("Request failed after max retries")
Error Handling Best Practices#
1. Always Check Status Codes#
# ✅ Good - Check status explicitly
response = requests.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
elif response.status_code == 404:
print("Resource not found")
elif response.status_code == 403:
print("Access denied")
else:
response.raise_for_status()
# ❌ Bad - Assume success
response = requests.get(url, headers=headers)
data = response.json() # Crashes on error
2. Parse OperationOutcome#
def parse_operation_outcome(response):
"""Extract error details from OperationOutcome."""
if not response.ok:
try:
outcome = response.json()
if outcome.get("resourceType") == "OperationOutcome":
issues = outcome.get("issue", [])
for issue in issues:
severity = issue.get("severity")
text = issue.get("details", {}).get("text", "Unknown error")
location = issue.get("location", [])
print(f"[{severity.upper()}] {text}")
if location:
print(f" Location: {', '.join(location)}")
except Exception:
print(f"HTTP {response.status_code}: {response.text}")
3. Implement Retry Logic#
from typing import Callable
import time
def retry_with_backoff(
func: Callable,
max_retries: int = 3,
initial_delay: float = 1.0,
backoff_factor: float = 2.0,
retryable_statuses: set = {429, 500, 502, 503, 504}
):
"""Retry function with exponential backoff."""
delay = initial_delay
for attempt in range(max_retries):
try:
return func()
except requests.HTTPError as e:
if e.response.status_code not in retryable_statuses:
raise # Don't retry non-retryable errors
if attempt == max_retries - 1:
raise # Last attempt, give up
print(f"Request failed (attempt {attempt + 1}/{max_retries}), retrying in {delay}s...")
time.sleep(delay)
delay *= backoff_factor
# Usage
result = retry_with_backoff(
lambda: requests.get(url, headers=headers).json()
)
4. Log Errors Properly#
import logging
logger = logging.getLogger(__name__)
def api_request_with_logging(url, headers):
"""Make request with comprehensive error logging."""
try:
logger.info(f"Requesting: {url}")
response = requests.get(url, headers=headers)
response.raise_for_status()
logger.info(f"Success: {response.status_code}")
return response.json()
except requests.HTTPError as e:
logger.error(f"HTTP Error: {e.response.status_code} {e.response.reason}")
logger.error(f"URL: {url}")
logger.error(f"Response: {e.response.text}")
raise
except requests.RequestException as e:
logger.error(f"Request failed: {str(e)}")
raise
5. Handle Network Errors#
import requests
from requests.exceptions import ConnectionError, Timeout
def api_request_with_error_handling(url, headers, timeout=30):
"""Make request with comprehensive error handling."""
try:
response = requests.get(url, headers=headers, timeout=timeout)
response.raise_for_status()
return response.json()
except ConnectionError:
print("Connection error. Check network connectivity.")
raise
except Timeout:
print(f"Request timed out after {timeout}s")
raise
except requests.HTTPError as e:
print(f"HTTP error: {e.response.status_code}")
parse_operation_outcome(e.response)
raise
except Exception as e:
print(f"Unexpected error: {str(e)}")
raise
Complete Error Handler#
import requests
import time
import logging
from typing import Optional, Dict, Callable
logger = logging.getLogger(__name__)
class APIErrorHandler:
"""Comprehensive API error handler."""
def __init__(self, max_retries: int = 3, base_delay: float = 1.0):
self.max_retries = max_retries
self.base_delay = base_delay
def make_request(
self,
method: str,
url: str,
headers: Dict,
json_data: Optional[Dict] = None,
timeout: int = 30
) -> Dict:
"""
Make API request with comprehensive error handling.
Args:
method: HTTP method (GET, POST, PUT, etc.)
url: Request URL
headers: Request headers
json_data: JSON body (for POST/PUT)
timeout: Request timeout in seconds
Returns:
Response JSON
Raises:
APIError: On unrecoverable errors
"""
for attempt in range(self.max_retries):
try:
logger.info(f"{method} {url} (attempt {attempt + 1}/{self.max_retries})")
response = requests.request(
method,
url,
headers=headers,
json=json_data,
timeout=timeout
)
# Handle rate limiting
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
logger.warning(f"Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
continue
# Handle server errors with retry
if response.status_code >= 500:
if attempt < self.max_retries - 1:
delay = self.base_delay * (2 ** attempt)
logger.warning(f"Server error {response.status_code}. Retrying in {delay}s...")
time.sleep(delay)
continue
# Handle other errors
if not response.ok:
self._handle_error(response)
return response.json()
except requests.Timeout:
logger.error(f"Request timed out after {timeout}s")
if attempt < self.max_retries - 1:
delay = self.base_delay * (2 ** attempt)
time.sleep(delay)
continue
raise
except requests.ConnectionError as e:
logger.error(f"Connection error: {str(e)}")
if attempt < self.max_retries - 1:
delay = self.base_delay * (2 ** attempt)
time.sleep(delay)
continue
raise
raise Exception(f"Request failed after {self.max_retries} attempts")
def _handle_error(self, response):
"""Handle error responses."""
try:
outcome = response.json()
if outcome.get("resourceType") == "OperationOutcome":
issues = outcome.get("issue", [])
error_messages = []
for issue in issues:
severity = issue.get("severity", "error")
details = issue.get("details", {})
text = details.get("text", "Unknown error")
location = issue.get("location", [])
error_messages.append({
"severity": severity,
"text": text,
"location": location,
"diagnostics": issue.get("diagnostics")
})
logger.error(f"API error {response.status_code}:")
for error in error_messages:
logger.error(f" [{error['severity'].upper()}] {error['text']}")
if error['location']:
logger.error(f" Location: {', '.join(error['location'])}")
if error['diagnostics']:
logger.error(f" Diagnostics: {error['diagnostics']}")
except Exception:
logger.error(f"HTTP {response.status_code}: {response.text}")
response.raise_for_status()
# Usage
handler = APIErrorHandler(max_retries=3)
try:
result = handler.make_request(
method="GET",
url="https://api.openspp.org/api/v2/spp/Individual/...",
headers={"Authorization": f"Bearer {token}"}
)
print("Success:", result)
except requests.HTTPError as e:
print(f"Request failed: {e}")
Are You Stuck?#
Getting errors you don't understand?
Check the diagnostics field for technical details. Search the error code in the documentation.
Retries not working?
Verify you're only retrying retryable errors (429, 500, 502, 503). Don't retry 400, 401, 403, 404, or 422.
Error messages not helpful?
Enable debug logging: logging.basicConfig(level=logging.DEBUG). This shows full request/response details.
Errors in production but not development?
Check if production has different rate limits, timeouts, or network conditions. Add retry logic with exponential backoff.
Transaction failing partway through?
Transaction bundles should rollback fully. If not, report as a bug. Check that you're using type: "transaction", not "batch".
Next Steps#
Authentication - OAuth 2.0 setup
API Resources - Available resources
Batch Operations - Batch operations
Consent Management - Consent-based access
See Also#
HTTP Status Codes - HTTP status reference
FHIR OperationOutcome - OperationOutcome structure
REST API Error Handling - Problem Details for HTTP APIs
openspp.org