INTRODUCTION
In Lesson 2.5, you successfully implemented efficient pagination. But what happens when the TPP sends an invalid fromDateTime? Or when the consent has expired? Or when the ASPSP’s database is unreachable?
In open banking, error handling is not a debugging tool—it is a regulatory requirement. The OBIE v4.0 specification dedicates an entire section (4.4) to ErrorResponse structures. The CDR Rules require that error responses contain specific ErrorCode enumerations so that TPPs can programmatically retry or fail. The EBA’s RTS on CSC requires that SCA failures return specific error messages that inform the PSU why the challenge failed.
This lesson teaches you to implement RFC 7807 Problem Details—the standardised error response format adopted by OBIE and CDR. You will learn the exact mapping of HTTP status codes (400 vs. 403 vs. 409 vs. 429) to regulatory scenarios, how to include a traceId for end‑to‑end debugging, and how to implement a circuit breaker that returns 503 Service Unavailable when the backend is degraded.
By the end, your API will return errors that are machine‑parseable, human‑readable, and regulator‑auditable.
LEARNING OBJECTIVES
-
Implement RFC 7807 Problem Details—constructing theÂ
type,Âtitle,Âstatus,Âdetail, andÂinstance fields, and ensuring compliance with the OBIE v4.0ÂErrorResponse schema. -
Map regulatory scenarios to correct HTTP status codes—distinguishing betweenÂ
400 Bad Request (schema validation),Â401 Unauthorized (missing/invalid token),Â403 Forbidden (insufficient consent),Â409 Conflict (idempotency clash), andÂ429 Too Many Requests (rate limiting). -
Design an OBIE‑specificÂ
ErrorCode enumeration—categories likeÂFIELD_VALIDATION,ÂCONSENT_INVALID,ÂPAYMENT_REJECTED,ÂSCA_FAILURE, andÂSERVER_TIMEOUT—and document them in the OpenAPI contract. -
Construct a circuit breaker (using Resilience4j or Hystrix) that returnsÂ
503 Service Unavailable when the backend dependency fails >50% of requests, and retry with backoff (Lesson 2.3) to recover. -
Quantify the latency overhead of error handling—including JSON serialisation of the error object (≤2ms) and logging to the audit trail (≤5ms)—ensuring that errors do not worsen the outage.
PART 1: RFC 7807 PROBLEM DETAILS — The Standard Error Format
1.1 The Structure
RFC 7807 defines a standardised JSON error payload that includes:
| Field | Required? | Type | Purpose |
|---|---|---|---|
type |
Yes | URI | A URI identifying the error type (e.g., https://api.bank.com/errors/field-validation) |
title |
Yes | String | A short, human‑readable summary (e.g., “Validation Error”) |
status |
Yes | Integer | HTTP status code (e.g., 400) |
detail |
Optional | String | Human‑readable explanation (e.g., “The ‘fromDateTime’ field is in the future”) |
instance |
Optional | URI | URI identifying the specific occurrence (e.g., /payments/ref-123) |
OBIE v4.0 ErrorResponse extends this with ErrorCode and ErrorDescription:
{ "type": "https://openbanking.org.uk/errors/field-validation", "title": "Validation Error", "status": 400, "detail": "The 'fromDateTime' field cannot be in the future.", "instance": "/payments/ref-123", "ErrorCode": "FIELD_VALIDATION_FUTURE_DATE", "ErrorDescription": "The 'fromDateTime' parameter is set to a future date." }
1.2 OpenAPI Declaration for Error Responses
components: schemas: ErrorResponse: type: object required: - type - title - status - ErrorCode - ErrorDescription properties: type: type: string format: uri example: "https://openbanking.org.uk/errors/field-validation" title: type: string example: "Validation Error" status: type: integer example: 400 detail: type: string example: "The 'fromDateTime' field cannot be in the future." instance: type: string format: uri example: "/payments/ref-123" ErrorCode: type: string example: "FIELD_VALIDATION_FUTURE_DATE" ErrorDescription: type: string example: "The 'fromDateTime' parameter is set to a future date." traceId: type: string format: uuid description: "Unique identifier for the request (echoed from x-fapi-interaction-id)"
1.3 Error Response Generation in Python
import uuid from flask import jsonify, request class ErrorResponseBuilder: @staticmethod def build(error_code, title, detail, status_code, instance=None): return jsonify({ "type": f"https://api.bank.com/errors/{error_code.lower()}", "title": title, "status": status_code, "detail": detail, "instance": instance or request.path, "ErrorCode": error_code, "ErrorDescription": detail, "traceId": request.headers.get('x-fapi-interaction-id', str(uuid.uuid4())) }), status_code
PART 2: HTTP STATUS CODE MAPPING — Regulatory Scenarios
| Scenario | HTTP Status | OBIE Error Code | Example |
|---|---|---|---|
| Missing mandatory field | 400 Bad Request | FIELD_VALIDATION_MISSING |
“The ‘Permissions’ field is required.” |
| Invalid date format | 400 Bad Request | FIELD_VALIDATION_FORMAT |
“The ‘fromDateTime’ must be ISO 8601.” |
| Missing OAuth2 token | 401 Unauthorized | AUTH_TOKEN_MISSING |
“Bearer token is required.” |
| Invalid OAuth2 token | 401 Unauthorized | AUTH_TOKEN_INVALID |
“Token has expired or is malformed.” |
| Insufficient consent scope | 403 Forbidden | CONSENT_INSUFFICIENT_SCOPE |
“Consent does not include ‘transactions:read’.” |
| Consent revoked | 403 Forbidden | CONSENT_REVOKED |
“Consent ct-123 has been revoked.” |
| Consent expired | 403 Forbidden | CONSENT_EXPIRED |
“Consent ct-123 expired on 2026-08-01.” |
| Idempotency conflict | 409 Conflict | IDEMPOTENCY_CONFLICT |
“Key ‘pay-001’ used with different payload.” |
| Rate limit exceeded | 429 Too Many Requests | RATE_LIMIT_EXCEEDED |
“25 RPS limit exceeded. Retry after 3s.” |
| SCA challenge failed | 400 Bad Request | SCA_FAILURE_INVALID_OTP |
“The OTP code is incorrect.” |
| Backend timeout | 504 Gateway Timeout | BACKEND_TIMEOUT |
“The ledger system did not respond in time.” |
| General server error | 500 Internal Server Error | SERVER_INTERNAL_ERROR |
“Unexpected error. Trace ID: abc-123.” |
2.1 Handling 401 vs. 403
-
401 Unauthorized: The TPP did not provide valid credentials (missing or invalid JWT). The TPP should retry with a new token.
-
403 Forbidden: The TPP is authenticated, but does not have permission (consent revoked, expired, or insufficient scope). The TPP must prompt the PSU to re‑authorise.
2.2 Handling 409 Conflict
As detailed in Lesson 2.3, the 409 response must return the Location of the previously created resource.
{ "type": "https://api.bank.com/errors/idempotency-conflict", "title": "Idempotency Conflict", "status": 409, "detail": "Idempotency key 'pay-001' already used with different payload.", "ErrorCode": "IDEMPOTENCY_CONFLICT", "ErrorDescription": "The provided idempotency key matches a previous request with a different payload.", "PreviousResourceUri": "/payments/pay-123" }
PART 3: RATE LIMITING (429) AND RETRY-AFTER
3.1 The 429 Response
When the TPP exceeds 25 RPS (UK/ CDR baseline), the ASPSP returns 429 Too Many Requests with a Retry-After header.
Response:
HTTP/1.1 429 Too Many Requests
Retry-After: 3
Content-Type: application/json
{
"type": "https://api.bank.com/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded the 25 RPS limit. Please retry after 3 seconds.",
"ErrorCode": "RATE_LIMIT_EXCEEDED",
"ErrorDescription": "API rate limit of 25 requests per second has been exceeded."
}
3.2 Token Bucket Algorithm Implementation
import time import threading from collections import defaultdict class TokenBucket: def __init__(self, rate=25, capacity=50): self.rate = rate # 25 tokens/second self.capacity = capacity # Burst up to 50 self.tokens = defaultdict(lambda: capacity) self.last_refill = defaultdict(lambda: time.time()) self.lock = threading.Lock() def allow_request(self, client_id): with self.lock: now = time.time() # Refill tokens based on time elapsed elapsed = now - self.last_refill[client_id] new_tokens = elapsed * self.rate self.tokens[client_id] = min(self.capacity, self.tokens[client_id] + new_tokens) self.last_refill[client_id] = now if self.tokens[client_id] >= 1: self.tokens[client_id] -= 1 return True, None else: # Calculate retry-after deficit = 1 - self.tokens[client_id] wait_time = deficit / self.rate return False, int(wait_time) + 1
Latency overhead: The token bucket check is purely in‑memory (no network calls). It adds ≤0.1ms to the request.
PART 4: CIRCUIT BREAKERS AND 503 — Handling Backend Failures
4.1 The Circuit Breaker Pattern
If the ASPSP’s ledger system or database is failing, returning 500 Internal Server Error for every request is not helpful—it overwhelms the TPP’s retry logic and causes cascading failures. Instead, implement a circuit breaker that returns 503 Service Unavailable when the failure rate exceeds a threshold.
States:
-
CLOSED: Requests pass through. Failure rate monitored.
-
OPEN: Requests are blocked; immediately return 503. (Default: 10 seconds)
-
HALF‑OPEN: Allow limited requests to test if the backend has recovered.
4.2 Python Implementation (Resilience4j‑style)
import time import threading from enum import Enum class CircuitBreakerState(Enum): CLOSED = 1 OPEN = 2 HALF_OPEN = 3 class CircuitBreaker: def __init__(self, failure_threshold=0.5, sample_size=10, open_timeout=10): self.failure_threshold = failure_threshold self.sample_size = sample_size self.open_timeout = open_timeout # seconds self.state = CircuitBreakerState.CLOSED self.failure_count = 0 self.total_count = 0 self.last_open_time = 0 self.lock = threading.Lock() def allow_request(self): with self.lock: if self.state == CircuitBreakerState.OPEN: # Check if we should transition to HALF_OPEN if time.time() - self.last_open_time > self.open_timeout: self.state = CircuitBreakerState.HALF_OPEN return True return False return True def record_result(self, success): with self.lock: if self.state == CircuitBreakerState.OPEN: return self.total_count += 1 if not success: self.failure_count += 1 # Only evaluate if we have enough samples if self.total_count >= self.sample_size: failure_rate = self.failure_count / self.total_count if failure_rate >= self.failure_threshold: self.state = CircuitBreakerState.OPEN self.last_open_time = time.time() self.failure_count = 0 self.total_count = 0 def __call__(self, func): def wrapper(*args, **kwargs): if not self.allow_request(): return 503, {"ErrorCode": "CIRCUIT_OPEN", "detail": "Service temporarily unavailable."} try: result = func(*args, **kwargs) self.record_result(True) return result except Exception: self.record_result(False) raise return wrapper
4.3 Latency Overhead of Error Handling
| Operation | Latency (p95) |
|---|---|
| Circuit breaker state check (in‑memory) | ≤0.05ms |
| Error JSON serialisation | ≤2ms |
| Audit log write (async) | ≤5ms (asynchronous) |
| Total error overhead | ≤7ms |
This is negligible compared to the overall latency budget.
CLOSING — OPERATIONAL RISK OF POOR ERROR HANDLING
If error responses are inconsistent, unstandardised, or missing:
-
TPPs cannot programmatically handle errors → they retry indefinitely → thundering herd → system collapse.
-
Regulators cannot audit compliance → missingÂ
ErrorCode fields → the bank fails the OBIE compliance test. -
PSUs receive confusing messages → frustration → complaints to the FCA/Ombudsman.
Key takeaways:
-
RFC 7807 Problem Details is the standard format (adopted by OBIE and CDR).
-
HTTP status codes must be precise (401 vs. 403 vs. 409 vs. 429).
-
Retry-After is mandatory for 429 responses. -
Circuit breakers prevent cascading failures (return 503 when backend is