Idempotency and Retries
Building reliable integrations with Debitura through idempotent operations and smart retry strategies.
Overview
Network failures, timeouts, and service interruptions are inevitable. Idempotency and retry logic ensure your integration handles these gracefully without creating duplicate operations or data inconsistencies.
What is Idempotency?
Idempotency means making the same request multiple times produces the same result as making it once.
Why It Matters
Without idempotency:
POST /v1/cases → Network timeout
POST /v1/cases → Retry creates duplicate case ❌
With idempotency:
POST /v1/cases + Idempotency-Key: abc123 → Network timeout
POST /v1/cases + Idempotency-Key: abc123 → Retry returns original case ✓
Benefits
- Safe retries - No duplicate operations
- Automatic deduplication - Server handles duplicates
- Reliable recovery - From failures without side effects
- Simplified client code - No complex duplicate detection needed
Idempotency Keys
How It Works
Include an Idempotency-Key header with mutating requests (POST, PUT, PATCH):
POST /v1/cases \
-H "Authorization: Bearer deb_live_abc123..." \
-H "Idempotency-Key: 7f3e8c9a-1b2d-4e5f-9a8b-7c6d5e4f3a2b" \
-H "Content-Type: application/json" \
-d '{...}'
Server behavior:
- First request: Process normally, cache result with key
- Duplicate request: Return cached result (same status, body, headers)
- Key expires: After 24 hours (configurable)
Generating Idempotency Keys
Requirements:
- Unique per operation
- Consistent for same logical operation
- Hard to guess (UUID recommended)
Recommended: UUID v4
// Node.js
const { v4: uuidv4 } = require('uuid');
const key = uuidv4();
// Python
import uuid
key = str(uuid.uuid4())
// C#
using System;
var key = Guid.NewGuid().ToString();
Alternative: Hash-based
// For consistent retry keys
const crypto = require('crypto');
function generateIdempotencyKey(operation, data) {
const payload = JSON.stringify({ operation, data, timestamp: Date.now() });
return crypto.createHash('sha256').update(payload).digest('hex');
}
Idempotent Operations
Supported Endpoints
Idempotency keys are supported for:
Case management:
POST /v1/cases- Create casePUT /v1/cases/{id}- Update casePOST /v1/cases/{id}/suspend- Suspend casePOST /v1/cases/{id}/resume- Resume case
Payment operations:
POST /v1/payments- Record paymentPOST /v1/payment-plans- Create payment plan
Configuration:
POST /v1/webhooks- Create webhookPOST /v1/bank-accounts- Add bank account
Not Required for
Safe methods (already idempotent):
GETrequests - Read operationsDELETErequests - Deletion is naturally idempotent
Implementation Examples
Basic Usage
JavaScript/Node.js:
const axios = require('axios');
const { v4: uuidv4 } = require('uuid');
async function createCase(caseData) {
const idempotencyKey = uuidv4();
try {
const response = await axios.post(
'https://api.debitura.com/v1/cases',
caseData,
{
headers: {
'Authorization': `Bearer ${process.env.DEBITURA_API_KEY}`,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json'
}
}
);
return response.data;
} catch (error) {
// Error handling with retry
if (error.response?.status >= 500) {
// Server error - safe to retry with same key
return retryWithBackoff(() => createCase(caseData));
}
throw error;
}
}
Python:
import requests
import uuid
def create_case(case_data):
idempotency_key = str(uuid.uuid4())
headers = {
'Authorization': f'Bearer {os.environ["DEBITURA_API_KEY"]}',
'Idempotency-Key': idempotency_key,
'Content-Type': 'application/json'
}
try:
response = requests.post(
'https://api.debitura.com/v1/cases',
json=case_data,
headers=headers
)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
if e.response.status_code >= 500:
# Server error - retry with same key
return retry_with_backoff(lambda: create_case(case_data))
raise
C#:
using System;
using System.Net.Http;
using System.Text.Json;
public async Task<Case> CreateCaseAsync(CaseData caseData)
{
var idempotencyKey = Guid.NewGuid().ToString();
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization",
$"Bearer {Environment.GetEnvironmentVariable("DEBITURA_API_KEY")}");
client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey);
var content = new StringContent(
JsonSerializer.Serialize(caseData),
System.Text.Encoding.UTF8,
"application/json"
);
try
{
var response = await client.PostAsync(
"https://api.debitura.com/v1/cases",
content
);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Case>();
}
catch (HttpRequestException ex) when (ex.StatusCode >= HttpStatusCode.InternalServerError)
{
// Server error - retry with exponential backoff
return await RetryWithBackoffAsync(() => CreateCaseAsync(caseData));
}
}
Retry Strategies
When to Retry
Retry-able errors:
- Network timeouts
- Connection failures
- HTTP 500 (Internal Server Error)
- HTTP 502 (Bad Gateway)
- HTTP 503 (Service Unavailable)
- HTTP 504 (Gateway Timeout)
Do NOT retry:
- HTTP 400 (Bad Request) - Fix your request
- HTTP 401 (Unauthorized) - Check API key
- HTTP 403 (Forbidden) - Insufficient permissions
- HTTP 404 (Not Found) - Resource doesn't exist
- HTTP 409 (Conflict) - Data conflict
- HTTP 422 (Validation Error) - Fix validation errors
- HTTP 429 (Rate Limited) - Use exponential backoff
Exponential Backoff
Recommended strategy:
async function retryWithBackoff(
operation,
maxRetries = 5,
initialDelayMs = 1000
) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
// Don't retry client errors
if (error.response?.status < 500) {
throw error;
}
// Calculate delay with exponential backoff and jitter
const delay = initialDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * delay * 0.1; // 10% jitter
const totalDelay = delay + jitter;
console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${totalDelay}ms`);
// Wait before retry
await new Promise(resolve => setTimeout(resolve, totalDelay));
}
}
throw lastError;
}
Delay progression:
Attempt 1: ~1000ms (1 second)
Attempt 2: ~2000ms (2 seconds)
Attempt 3: ~4000ms (4 seconds)
Attempt 4: ~8000ms (8 seconds)
Attempt 5: ~16000ms (16 seconds)
Rate Limiting Retries
When you receive HTTP 429 (Rate Limited):
async function handleRateLimitRetry(operation) {
try {
return await operation();
} catch (error) {
if (error.response?.status === 429) {
// Check for Retry-After header
const retryAfter = error.response.headers['retry-after'];
const delayMs = retryAfter
? parseInt(retryAfter) * 1000
: 60000; // Default 60s
console.log(`Rate limited. Retrying after ${delayMs}ms`);
await new Promise(resolve => setTimeout(resolve, delayMs));
return await operation();
}
throw error;
}
}
Best Practices
Idempotency Key Management
-
Generate once, use consistently
- Store key before first request
- Reuse same key for all retries of that operation
-
Scope appropriately
- Different keys for different operations
- Same key only for identical operations
-
Include in logs
- Helps correlate requests
- Aids debugging and audit
-
Don't reuse across operations
- Never use same key for different data
- Can lead to incorrect cached responses
Example storage:
class CaseCreator {
constructor() {
this.pendingOperations = new Map();
}
async createCase(caseData) {
// Generate or retrieve idempotency key
const operationId = this.hashCaseData(caseData);
let idempotencyKey = this.pendingOperations.get(operationId);
if (!idempotencyKey) {
idempotencyKey = uuidv4();
this.pendingOperations.set(operationId, idempotencyKey);
}
try {
const result = await this.performCreate(caseData, idempotencyKey);
this.pendingOperations.delete(operationId);
return result;
} catch (error) {
// Keep key for retry
throw error;
}
}
}
Retry Logic
- Implement exponential backoff - Reduce server load
- Add jitter - Prevent thundering herd
- Set maximum retries - Prevent infinite loops
- Log all attempts - For debugging
- Respect Retry-After headers - For rate limits
- Use circuit breakers - Fail fast when service is down
Error Handling
-
Distinguish error types
- Permanent (4xx) vs transient (5xx)
- Retry only transient errors
-
Preserve original error
- Don't lose error context
- Include retry count in logs
-
Notify appropriately
- Alert on max retries exhausted
- Log each retry attempt
- Track retry metrics
Advanced Patterns
Idempotent Webhooks
Process webhooks idempotently using event IDs:
// Store processed event IDs
const processedEvents = new Set();
app.post('/webhooks/debitura', async (req, res) => {
const { id, type, data } = req.body;
// Acknowledge immediately
res.status(200).json({ received: true });
// Check if already processed
if (processedEvents.has(id)) {
console.log(`Duplicate webhook ${id} - skipping`);
return;
}
try {
await processWebhook(type, data);
processedEvents.add(id);
} catch (error) {
console.error(`Webhook ${id} processing failed:`, error);
// Will be retried by Debitura
}
});
Production approach (with database):
async function processWebhookIdempotent(webhook) {
const { id, type, data } = webhook;
// Try to insert event ID (unique constraint)
const inserted = await db.execute(
'INSERT INTO processed_webhooks (event_id, processed_at) VALUES (?, NOW())',
[id]
);
if (!inserted) {
console.log(`Webhook ${id} already processed`);
return;
}
// Process webhook
await handleWebhookEvent(type, data);
}
Queue-Based Retry
For critical operations, use message queues:
// Producer (API client)
async function createCaseWithQueue(caseData) {
const job = {
operation: 'create_case',
data: caseData,
idempotencyKey: uuidv4(),
attempts: 0,
maxAttempts: 5
};
await queue.add('debitura-operations', job);
}
// Consumer (worker)
queue.process('debitura-operations', async (job) => {
const { operation, data, idempotencyKey, attempts, maxAttempts } = job.data;
try {
const result = await debituraApi.createCase(data, idempotencyKey);
return result;
} catch (error) {
if (error.response?.status >= 500 && attempts < maxAttempts) {
// Requeue with exponential backoff
const delay = Math.pow(2, attempts) * 1000;
job.data.attempts++;
await queue.add('debitura-operations', job.data, { delay });
}
throw error;
}
});
Testing Idempotency
Test Scenarios
-
Exact duplicate requests
const key = uuidv4();
const result1 = await createCase(data, key);
const result2 = await createCase(data, key);
assert.deepEqual(result1, result2); -
Concurrent requests
const key = uuidv4();
const [result1, result2] = await Promise.all([
createCase(data, key),
createCase(data, key)
]);
assert.deepEqual(result1, result2); -
Retry after timeout
const key = uuidv4();
try {
await createCaseWithTimeout(data, key, 1); // 1ms timeout
} catch (timeoutError) {
// Retry with same key
const result = await createCase(data, key);
assert.ok(result);
}
Monitoring and Debugging
Metrics to Track
- Retry rate - Percentage of requests retried
- Retry count distribution - How many attempts before success
- Idempotency cache hits - Duplicate requests detected
- Failed after max retries - Operations that gave up
- Average retry latency - Time spent in retries
Logging Best Practices
logger.info('API request', {
operation: 'create_case',
idempotencyKey: key,
attempt: 1,
caseId: result.id
});
logger.warn('Retry attempt', {
operation: 'create_case',
idempotencyKey: key,
attempt: 2,
error: error.message,
delayMs: 2000
});
logger.error('Max retries exhausted', {
operation: 'create_case',
idempotencyKey: key,
attempts: 5,
lastError: error.message
});
Next Steps
- Understand webhooks and events for reliable event processing
- Review API reference for idempotency-supported endpoints
- Learn about security best practices