Skip to main content

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

  1. Safe retries - No duplicate operations
  2. Automatic deduplication - Server handles duplicates
  3. Reliable recovery - From failures without side effects
  4. 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:

  1. First request: Process normally, cache result with key
  2. Duplicate request: Return cached result (same status, body, headers)
  3. 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 case
  • PUT /v1/cases/{id} - Update case
  • POST /v1/cases/{id}/suspend - Suspend case
  • POST /v1/cases/{id}/resume - Resume case

Payment operations:

  • POST /v1/payments - Record payment
  • POST /v1/payment-plans - Create payment plan

Configuration:

  • POST /v1/webhooks - Create webhook
  • POST /v1/bank-accounts - Add bank account

Not Required for

Safe methods (already idempotent):

  • GET requests - Read operations
  • DELETE requests - 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

  1. Generate once, use consistently

    • Store key before first request
    • Reuse same key for all retries of that operation
  2. Scope appropriately

    • Different keys for different operations
    • Same key only for identical operations
  3. Include in logs

    • Helps correlate requests
    • Aids debugging and audit
  4. 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

  1. Implement exponential backoff - Reduce server load
  2. Add jitter - Prevent thundering herd
  3. Set maximum retries - Prevent infinite loops
  4. Log all attempts - For debugging
  5. Respect Retry-After headers - For rate limits
  6. Use circuit breakers - Fail fast when service is down

Error Handling

  1. Distinguish error types

    • Permanent (4xx) vs transient (5xx)
    • Retry only transient errors
  2. Preserve original error

    • Don't lose error context
    • Include retry count in logs
  3. 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

  1. Exact duplicate requests

    const key = uuidv4();
    const result1 = await createCase(data, key);
    const result2 = await createCase(data, key);

    assert.deepEqual(result1, result2);
  2. Concurrent requests

    const key = uuidv4();
    const [result1, result2] = await Promise.all([
    createCase(data, key),
    createCase(data, key)
    ]);

    assert.deepEqual(result1, result2);
  3. 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