Skip to main content

Idempotency

Idempotency keys ensure that retrying a request doesn't create duplicate resources. This is critical for production systems where network failures or timeouts can cause accidental retries.

What is idempotency?

Idempotent operations produce the same result when called multiple times with the same parameters. In the Mappa API, idempotency prevents:

  • Duplicate file uploads due to network retries
  • Multiple report jobs for the same request
  • Duplicate feedback submissions
  • Double-charging credits

How it works

  1. Client generates a unique key - e.g., upload_20260117_user123_v1
  2. First request - API processes normally and caches the response
  3. Retry requests - API returns cached response (same status code and body)
  4. Cache expiry - Cached responses expire after 24 hours

Endpoints supporting idempotency

EndpointIdempotency Key
POST /v1/filesHeader or body
POST /v1/reports/jobsBody only
POST /v1/feedbackBody only
POST /v1/jobs/:jobId/cancelHeader only

Using idempotency keys

Send the Idempotency-Key header with any POST request:

cURL with Header
curl -X POST https://api.mappa.ai/v1/files \
-H "Mappa-Api-Key: $MAPPA_API_KEY" \
-H "Idempotency-Key: upload_20260117_file_abc_v1" \
-F "file=@interview.mp4" \
-F "contentType=video/mp4"
Node.js with Header
import { Mappa } from "@mappa-ai/mappa-node";

const mappa = new Mappa({ apiKey: process.env.MAPPA_API_KEY! });

const file = await mappa.files.upload({
file: "./interview.mp4",
idempotencyKey: "upload_20260117_file_abc_v1",
});

Method 2: request body

Some endpoints also accept idempotencyKey in the request body:

Report Job with Body Key
const job = await mappa.reports.createJob({
media: { mediaId: "media_abc123" },
output: { template: "general_report" },
target: { strategy: "dominant" },
idempotencyKey: "report_20260117_user456_v1",
});
Feedback with Body Key
await mappa.feedback.create({
jobId: "job_xyz789",
rating: "thumbs_up",
idempotencyKey: "feedback_20260117_job_xyz_v1",
});

Generating idempotency keys

Best practices

Use UUIDs or unique identifiers:

import { randomUUID } from "crypto";

const idempotencyKey = `upload_${randomUUID()}`;

Include operation type and timestamp:

const idempotencyKey = `report_${Date.now()}_${userId}_${mediaId}`;

Make keys deterministic for true retries:

// If you want retries to be idempotent, use deterministic keys
const idempotencyKey = `upload_${userId}_${fileHash}_v1`;

Avoid:

  • Sequential numbers (not globally unique)
  • Too short (risk of collisions)
  • Including sensitive data

Format recommendations

// Good patterns
`{operation}_{timestamp}_{userId}_{resourceId}`
`{operation}_{uuid}`
`{operation}_{date}_{context}_v{version}`

// Examples
"upload_1705516800_user123_abc_v1"
"report_550e8400-e29b-41d4-a716-446655440000"
"feedback_20260117_job_xyz_v1"

Complete example: resilient file upload

Handle network failures and retries safely:

resilient-upload.ts
import { Mappa } from "@mappa-ai/mappa-node";
import { createHash } from "crypto";
import { readFile } from "fs/promises";

const mappa = new Mappa({ apiKey: process.env.MAPPA_API_KEY! });

async function uploadWithRetry(
filePath: string,
maxRetries = 3
): Promise<string> {
// Generate deterministic idempotency key based on file content
const fileBuffer = await readFile(filePath);
const fileHash = createHash("sha256").update(fileBuffer).digest("hex");
const idempotencyKey = `upload_${fileHash.slice(0, 16)}`;

let lastError: Error | undefined;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Upload attempt ${attempt}/${maxRetries}...`);

const file = await mappa.files.upload({
file: filePath,
idempotencyKey,
});

console.log(`✓ Upload successful: ${file.mediaId}`);
return file.mediaId;

} catch (error) {
lastError = error as Error;
console.error(`✗ Attempt ${attempt} failed:`, error.message);

// Don't retry on validation errors
if (error.status === 400 || error.status === 422) {
throw error;
}

// Wait before retry (exponential backoff)
if (attempt < maxRetries) {
const delayMs = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
console.log(`Waiting ${delayMs}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}

throw lastError || new Error("Upload failed after retries");
}

// Usage
try {
const mediaId = await uploadWithRetry("./interview.mp4");
console.log(`Media ready: ${mediaId}`);
} catch (error) {
console.error("Upload failed permanently:", error);
}

Key points:

  • Uses file hash for deterministic key
  • Retries network errors but not validation errors
  • Exponential backoff between retries
  • Idempotency ensures no duplicates even with retries

Complete example: resilient report creation

resilient-report-creation.ts
async function createReportWithRetry(
mediaId: string,
template: string,
maxRetries = 3
): Promise<string> {
// Deterministic key based on inputs
const idempotencyKey = `report_${mediaId}_${template}_${Date.now()}`;

let lastError: Error | undefined;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Creating report (attempt ${attempt})...`);

const job = await mappa.reports.createJob({
media: { mediaId },
output: { template },
target: { strategy: "dominant" },
idempotencyKey, // ← Prevents duplicate jobs
});

console.log(`✓ Job created: ${job.jobId}`);

// Wait for completion
const report = await job.handle!.wait();
return report.id;

} catch (error) {
lastError = error as Error;

// Don't retry insufficient credits or validation errors
if (error.status === 402 || error.status === 422) {
throw error;
}

if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 2000 * attempt));
}
}
}

throw lastError;
}

Idempotency window

Cached responses are stored for 24 hours, then automatically deleted. After 24 hours, the same idempotency key can be reused.

Implications

Safe retries within 24 hours - Won't create duplicates
Automatic cleanup - No manual cache management
Don't reuse keys across different operations - Each operation needs unique key

Example: retry window

// Day 1, 10:00 AM - First request
await mappa.files.upload({
file: "./video.mp4",
idempotencyKey: "upload_abc123",
});
// Response cached

// Day 1, 10:05 AM - Retry (within window)
await mappa.files.upload({
file: "./video.mp4",
idempotencyKey: "upload_abc123",
});
// Returns cached response, no duplicate upload

// Day 2, 11:00 AM - Retry (after 24 hours)
await mappa.files.upload({
file: "./video.mp4",
idempotencyKey: "upload_abc123",
});
// Cache expired, processes as new upload

Error handling with idempotency

Cached error responses

If the first request failed, the error is also cached:

// First attempt - fails with 402 (insufficient credits)
try {
await mappa.reports.createJob({
media: { mediaId },
output: { template: "general_report" },
target: { strategy: "dominant" },
idempotencyKey: "report_xyz",
});
} catch (error) {
console.error(error); // 402 error cached
}

// Retry with same key - returns same 402 error
try {
await mappa.reports.createJob({
media: { mediaId },
output: { template: "general_report" },
target: { strategy: "dominant" },
idempotencyKey: "report_xyz", // Same key
});
} catch (error) {
console.error(error); // Same cached 402 error
}

Solution: use new key after fixing error

// After adding credits, use a new idempotency key
await mappa.reports.createJob({
media: { mediaId },
output: { template: "general_report" },
target: { strategy: "dominant" },
idempotencyKey: "report_xyz_v2", // ← New key
});

Production patterns

Pattern 1: User-initiated operations

For operations triggered by users, include user and resource IDs:

function generateIdempotencyKey(
userId: string,
operation: string,
resourceId: string
): string {
return `${operation}_${userId}_${resourceId}_${Date.now()}`;
}

// Usage
const key = generateIdempotencyKey(
"user_123",
"report",
"media_abc"
);

await mappa.reports.createJob({
...params,
idempotencyKey: key,
target: { strategy: "dominant" },
});

Pattern 2: background jobs

For automated/scheduled operations, include job context:

function generateJobKey(
jobType: string,
jobId: string,
attempt: number
): string {
return `${jobType}_${jobId}_attempt_${attempt}`;
}

// Usage in batch processor
for (const item of queue) {
const key = generateJobKey("nightly_analysis", item.id, item.attempts);

await mappa.reports.createJob({
...item.params,
idempotencyKey: key,
target: { strategy: "dominant" },
});
}

Pattern 3: API gateway integration

For webhook or API integrations, use external request IDs:

// Express.js example
app.post("/api/analyze", async (req, res) => {
const idempotencyKey = req.headers["x-request-id"] as string;

if (!idempotencyKey) {
return res.status(400).json({ error: "Missing X-Request-Id header" });
}

try {
const job = await mappa.reports.createJob({
media: { mediaId: req.body.mediaId },
output: { template: "general_report" },
target: { strategy: "dominant" },
idempotencyKey,
});

res.json({ jobId: job.jobId });
} catch (error) {
res.status(500).json({ error: error.message });
}
});

Best practices

✅ Do

  • Generate unique keys for each operation
  • Include operation type in key
  • Use deterministic keys for true retry logic
  • Handle cached error responses
  • Keep keys under 256 characters
  • Log idempotency keys for debugging

❌ Don't

  • Reuse keys across different operations
  • Include sensitive data in keys
  • Use sequential numbers only
  • Retry with same key after fixing errors
  • Assume keys last forever (24-hour window)

Testing idempotency

test-idempotency.ts
import { Mappa } from "@mappa-ai/mappa-node";

const mappa = new Mappa({ apiKey: process.env.MAPPA_API_KEY! });

async function testIdempotency() {
const idempotencyKey = `test_${Date.now()}`;

console.log("First request...");
const response1 = await mappa.reports.createJob({
media: { mediaId: "media_test123" },
output: { template: "general_report" },
target: { strategy: "dominant" },
idempotencyKey,
});

console.log(`First job ID: ${response1.jobId}`);

console.log("\nSecond request (same key)...");
const response2 = await mappa.reports.createJob({
media: { mediaId: "media_test123" },
output: { template: "general_report" },
target: { strategy: "dominant" },
idempotencyKey, // Same key
});

console.log(`Second job ID: ${response2.jobId}`);

// Should be identical
console.log(`\nIDs match: ${response1.jobId === response2.jobId}`); // true
console.log("✓ Idempotency working correctly!");
}

testIdempotency();

Troubleshooting

Issue: still getting duplicates

Cause: Different idempotency keys used

Solution: Ensure consistent key generation

// ❌ Bad: Random keys
const key1 = `upload_${Math.random()}`;
const key2 = `upload_${Math.random()}`; // Different!

// ✅ Good: Deterministic keys
const key1 = `upload_${userId}_${fileHash}`;
const key2 = `upload_${userId}_${fileHash}`; // Same!

Issue: cached error persists

Cause: First request failed, error is cached

Solution: Use new idempotency key after fixing the issue

// First try - fails with 402
await createReport({ idempotencyKey: "report_v1" }); // Fails

// Add credits, then use NEW key
await createReport({ idempotencyKey: "report_v2" }); // Success

Issue: key too long

Cause: Idempotency key exceeds 256 characters

Solution: Hash long keys or use shorter format

import { createHash } from "crypto";

// ❌ Bad: Too long
const key = `upload_${veryLongUserId}_${veryLongFilename}_${timestamp}...`;

// ✅ Good: Hash it
const key = createHash("sha256")
.update(`upload_${userId}_${filename}_${timestamp}`)
.digest("hex")
.slice(0, 32);

Next steps