Fallback and Idempotency — Deep Dive
Level: Intermediate
Pre-reading: 06 · Resilience & Reliability
Fallback Patterns
When a service call fails, fallback provides an alternative response instead of propagating the error.
Fallback Strategies
| Strategy | Description | Example |
|---|---|---|
| Default value | Return a sensible constant | Empty list, default price |
| Cached response | Return last known good value | Cached product details |
| Degraded mode | Return partial data | Order without live pricing |
| Stub response | Hardcoded response | "Service unavailable" message |
| Alternative service | Call backup service | Secondary payment provider |
Fallback Implementation
Resilience4j Fallback
@Service
public class RecommendationService {
@CircuitBreaker(name = "recommendations", fallbackMethod = "fallbackRecommendations")
@Retry(name = "recommendations", fallbackMethod = "fallbackRecommendations")
public List<Product> getRecommendations(String userId) {
return recommendationClient.getPersonalized(userId);
}
// Fallback method - must have same return type
private List<Product> fallbackRecommendations(String userId, Exception e) {
log.warn("Recommendations unavailable for {}: {}", userId, e.getMessage());
// Return cached popular products
return cache.get("popular-products", () ->
productService.getPopular(10)
);
}
}
Fallback Chain
@CircuitBreaker(name = "primary", fallbackMethod = "trySecondary")
public PaymentResult charge(Order order) {
return primaryPaymentProvider.charge(order);
}
private PaymentResult trySecondary(Order order, Exception e) {
log.warn("Primary payment failed, trying secondary: {}", e.getMessage());
try {
return secondaryPaymentProvider.charge(order);
} catch (Exception e2) {
return queueForLater(order, e2);
}
}
private PaymentResult queueForLater(Order order, Exception e) {
paymentQueue.enqueue(order);
return PaymentResult.pending("Queued for processing");
}
Graceful Degradation
Progressively disable features as system health decreases.
graph TD
H[Healthy] --> D1[Degraded Level 1: Disable recommendations]
D1 --> D2[Degraded Level 2: Disable personalization]
D2 --> D3[Degraded Level 3: Read-only mode]
D3 --> M[Maintenance mode]
Feature Flags for Degradation
@Service
public class ProductService {
private final FeatureFlags features;
public ProductDetails getProduct(String productId) {
ProductDetails product = productRepository.findById(productId);
if (features.isEnabled("personalization")) {
product.setRecommendations(getRecommendations(productId));
}
if (features.isEnabled("live-pricing")) {
product.setPrice(getPriceFromPricingService(productId));
} else {
product.setPrice(product.getCachedPrice());
}
if (features.isEnabled("reviews")) {
product.setReviews(getReviews(productId));
}
return product;
}
}
Idempotency
An idempotent operation produces the same result whether called once or multiple times.
Why Idempotency Matters
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: POST /payments (charge $100)
S->>DB: INSERT payment
S--xC: Response lost (network error)
Note over C: Did it work?
C->>S: POST /payments (retry)
S->>DB: INSERT payment (DUPLICATE!)
With idempotency key:
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: POST /payments, Idempotency-Key: abc123
S->>DB: INSERT payment (key: abc123)
S--xC: Response lost
C->>S: POST /payments, Idempotency-Key: abc123 (retry)
S->>DB: SELECT WHERE key = abc123
S->>C: Return cached result
Implementing Idempotency
Idempotency Key Pattern
@RestController
public class PaymentController {
private final IdempotencyStore idempotencyStore;
@PostMapping("/payments")
public ResponseEntity<PaymentResult> createPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody PaymentRequest request) {
// Check for existing result
Optional<PaymentResult> existing = idempotencyStore.get(idempotencyKey);
if (existing.isPresent()) {
return ResponseEntity.ok(existing.get());
}
// Process new payment
PaymentResult result = paymentService.charge(request);
// Store result
idempotencyStore.put(idempotencyKey, result, Duration.ofHours(24));
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
}
Idempotency Store
@Component
public class RedisIdempotencyStore implements IdempotencyStore {
private final RedisTemplate<String, String> redis;
@Override
public Optional<PaymentResult> get(String key) {
String json = redis.opsForValue().get("idempotency:" + key);
return Optional.ofNullable(json)
.map(j -> objectMapper.readValue(j, PaymentResult.class));
}
@Override
public void put(String key, PaymentResult result, Duration ttl) {
redis.opsForValue().set(
"idempotency:" + key,
objectMapper.writeValueAsString(result),
ttl
);
}
}
Database-Level Idempotency
-- Use unique constraint on idempotency key
CREATE TABLE payments (
id UUID PRIMARY KEY,
idempotency_key VARCHAR(255) UNIQUE,
amount DECIMAL(10,2),
status VARCHAR(50),
created_at TIMESTAMP
);
-- Insert with conflict handling
INSERT INTO payments (id, idempotency_key, amount, status)
VALUES (gen_random_uuid(), 'abc123', 100.00, 'completed')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING *;
Idempotent by Design
Some operations are naturally idempotent:
| Method | Idempotent? | Notes |
|---|---|---|
| GET | Yes | Read-only |
| PUT | Yes | Same resource state |
| DELETE | Yes | Resource stays deleted |
| POST (create) | No | Creates new each time |
| PATCH | Depends | Some patches are, some aren't |
Making POST Idempotent
// Non-idempotent: creates new each time
POST /orders
{"items": [...]}
// Idempotent with client-generated ID
PUT /orders/client-generated-uuid
{"items": [...]}
// Idempotent with idempotency key
POST /orders
Idempotency-Key: unique-key
{"items": [...]}
Handling In-Progress Requests
What if a retry arrives while the first request is still processing?
public PaymentResult processWithLock(String idempotencyKey, PaymentRequest request) {
// Try to acquire lock
boolean locked = lockService.tryLock(idempotencyKey, Duration.ofMinutes(5));
if (!locked) {
// Another request is processing
throw new ConflictException("Request in progress");
}
try {
// Check if already completed
Optional<PaymentResult> existing = idempotencyStore.get(idempotencyKey);
if (existing.isPresent()) {
return existing.get();
}
// Process
PaymentResult result = paymentService.charge(request);
idempotencyStore.put(idempotencyKey, result);
return result;
} finally {
lockService.unlock(idempotencyKey);
}
}
Idempotency Key Best Practices
| Practice | Rationale |
|---|---|
| Client generates key | Client controls retry behavior |
| UUID format | Globally unique |
| Include in request | Server doesn't guess |
| TTL for storage | Clean up old keys |
| Return cached response | Same response for same key |
Key Generation
// Client generates key
const idempotencyKey = `order-${orderId}-${Date.now()}-${uuid()}`;
fetch('/api/payments', {
method: 'POST',
headers: {
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(paymentData)
});
Combining Fallback and Idempotency
@CircuitBreaker(name = "payment", fallbackMethod = "paymentFallback")
@Retry(name = "payment")
public PaymentResult charge(String idempotencyKey, Order order) {
return paymentClient.charge(idempotencyKey, order);
}
private PaymentResult paymentFallback(String idempotencyKey, Order order, Exception e) {
// Queue for later - include idempotency key
paymentQueue.enqueue(new QueuedPayment(idempotencyKey, order));
return PaymentResult.pending("Payment queued");
}
// Background processor uses same idempotency key
@Scheduled(fixedDelay = 5000)
public void processQueue() {
QueuedPayment queued = paymentQueue.poll();
if (queued != null) {
// Retry with same idempotency key - safe even if original succeeded
paymentClient.charge(queued.getIdempotencyKey(), queued.getOrder());
}
}
When should you use fallback vs let the error propagate?
Use fallback when: (1) The feature is non-critical. (2) A degraded response is better than error. (3) You have meaningful cached/default data. Propagate error when: (1) The operation is critical (payment). (2) No sensible fallback exists. (3) User should know about failure.
How long should you store idempotency keys?
Balance between: (1) Long enough for retry windows (24h is common). (2) Short enough to not exhaust storage. Consider: client retry behavior, operation type (payments may need longer), storage costs. 24–72 hours is typical for payment APIs.
What happens if idempotency store fails?
Options: (1) Fail closed — reject request (safest for money). (2) Fail open — process without check (risk duplicates). (3) Circuit breaker — fallback to in-memory or database check. For financial operations, fail closed is usually right.