Skip to content

Idempotency

Idempotency ensures duplicate mutation requests are safely handled without creating duplicate records. Critical for offline retry scenarios, network timeouts, and double-tap prevention.

The system uses the X-Idempotency-Key header with a database-backed cache (24-hour TTL).

sequenceDiagram
    participant Client as Mobile Client
    participant Middleware as Idempotency Middleware
    participant Handler as API Handler
    
    Client->>Middleware: POST /resource {key: "uuid"}
    Middleware->>Middleware: Check idempotency_keys table
    alt Key exists (within 24h, status 2xx)
        Middleware-->>Client: 204 No Content (already processed)
    else Key new or failed
        Middleware->>Handler: Process request
        Handler-->>Middleware: Success response
        Middleware->>Middleware: Store key + status (upsert)
        Middleware-->>Client: 200/201 OK
    end

Flow:

  1. Client generates UUID v4 for each mutation
  2. UUID included in X-Idempotency-Key header
  3. Server checks workspace-scoped idempotency_keys table
  4. If exists with 2xx: Return 204 No Content
  5. If new/failed: Process, store key, return success
src/core/utils/uuid.ts
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
src/core/libs/api-client/api-client.ts
const WRITE_METHODS = new Set(['post', 'put', 'patch', 'delete']);
apiClient.interceptors.request.use(async config => {
if (WRITE_METHODS.has(config.method?.toLowerCase() ?? '')) {
config.headers['X-Idempotency-Key'] = generateUUID();
}
return config;
});
POST /app/v1/outlet-visits
Authorization: Bearer <token>
X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{
"outlet_id": 123,
"scheduled_date": "2026-03-10"
}
internal/middleware/idempotency.go
func IdempotencyMiddleware(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader("X-Idempotency-Key")
if key == "" {
c.Next()
return
}
auth, _ := utils.GetAuthContextValue(c.Request.Context())
if auth == nil {
c.Next()
return
}
workspaceID := auth.UserWorkspaceID
// Lazy eviction — delete keys older than 24h (background)
go db.Where("created_at < ?", time.Now().Add(-24*time.Hour)).
Delete(&idempotencyRecord{})
// Check for existing key
var existing idempotencyRecord
result := db.Where("workspace_id = ? AND key = ?", workspaceID, key).
First(&existing)
if result.Error == nil && existing.ResponseStatus >= 200 && existing.ResponseStatus < 300 {
utils.HandleResponse(c, http.StatusNoContent, "Duplicate request — already processed", "IDEMPOTENT_DUPLICATE", nil)
c.Abort()
return
}
c.Next()
// Store key + response status (upsert)
status := c.Writer.Status()
db.Where(idempotencyRecord{WorkspaceID: workspaceID, Key: key}).
Assign(idempotencyRecord{ResponseStatus: status}).
FirstOrCreate(&idempotencyRecord{
WorkspaceID: workspaceID,
Key: key,
ResponseStatus: status,
})
}
}
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
workspace_id BIGINT NOT NULL,
key VARCHAR(64) NOT NULL,
response_status INTEGER NOT NULL DEFAULT 200,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE KEY idx_workspace_idem_key (workspace_id, key),
INDEX idx_idempotency_keys_cleanup (created_at)
);

TTL: 24 hours

Rationale: Long enough for offline sync, short enough to prevent unbounded growth.

Eviction: Lazy — background goroutine deletes keys older than 24h on each request.

-- Optional manual cleanup (daily cron)
DELETE FROM idempotency_keys
WHERE created_at < NOW() - INTERVAL '24 hours';
HTTP/1.1 204 No Content
{
"success": true,
"message": "Duplicate request — already processed",
"code": "IDEMPOTENT_DUPLICATE",
"data": null
}

Behavior: Returns 204 No Content (not 409). Client treats as success — no retry needed.

  • Request processed normally (no error)
  • Critical for: sales orders, visit check-in/out, attendance
try {
await createVisit(data);
} catch (error) {
if (isNetworkError(error)) {
// Queue with same UUID — server deduplicates on retry
await outbox.enqueue({ type: 'createVisit', data, idempotencyKey: key });
}
}
Client sends → Timeout → Retry
Without idempotency: 2 visits created
With idempotency: 1 visit, 2nd returns 204
User taps "Submit" twice
Without idempotency: 2 submissions
With idempotency: 1 submission, 2nd blocked

DO:

  • Generate new UUID per distinct mutation
  • Store key with queued outbox operation
  • Reuse same key for retries
  • Log key for debugging

DON’T:

  • Reuse keys across different mutations
  • Use predictable keys (timestamps, counters)
  • Omit key for critical mutations

DO:

  • Check idempotency before business logic
  • Store key atomically (upsert pattern)
  • Use 24h TTL
  • Log conflicts for monitoring

DON’T:

  • Skip check for write operations
  • Use short TTL (<1h)
  • Store full response (just status)
  • Ignore workspace scoping