Idempotent retries

A production-ready Dcision client — idempotency keys, timeouts, which errors to retry, exponential backoff with Retry-After — in JavaScript and Python.

Networks fail. A request can time out on your side after the decision ran, a provider can be briefly overloaded, a burst can hit your rate limit. A good client retries the right errors, waits the right amount of time and never runs — or pays for — the same decision twice.

The official SDKs do all of this for you. This guide shows the rules and a complete client for when you call the API directly.

The rules

  1. One Idempotency-Key per operation, reused by every retry of it — the ID of the lead, ticket or message works well. A retry of a call that succeeded is then replayed for 24 hours instead of running again.
  2. Build the body the same way every time. The replay check compares the state as parsed JSON, key order included; a different body with the same key is 409 IDEMPOTENCY_CONFLICT.
  3. Time out on your side, a little after Dcision does. Dcision already retries the engine — up to 2 retries — within the decision's timeoutMs (5 seconds by default); 10 seconds on your side leaves room for it. Raise both together.
  4. Retry only what can succeed later: network errors, 429, 500, 503, 504, 409 IDEMPOTENCY_CONFLICT with Retry-After — the first request with the key is still running — and 502 ENGINE_ERROR once. Fix everything else.
  5. Honor Retry-After on 429 and 409; otherwise back off exponentially with jitter.
  6. Cap the attempts, then take your error path — usually the same as escalate.

JavaScript

dcision.js
import { randomUUID } from "node:crypto";

const API = "https://api.dcision.io/v1/decisions";
const RETRYABLE_STATUS = new Set([429, 500, 503, 504]);

export class DcisionError extends Error {
  constructor(status, error) {
    super(`${error.code}: ${error.message}`);
    Object.assign(this, { status, code: error.code, requestId: error.request_id, details: error.details });
  }
}

/** Runs a decision with retries. Pass a stable idempotencyKey (e.g. the record's ID) per operation. */
export async function decide(slug, state, { idempotencyKey = randomUUID(), attempts = 4, timeoutMs = 10_000 } = {}) {
  for (let attempt = 1; ; attempt++) {
    let response;
    try {
      response = await fetch(`${API}/${slug}`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.DCISION_API_KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify({ state }),
        signal: AbortSignal.timeout(timeoutMs),
      });
    } catch (networkError) {
      // The decision may or may not have run: the same Idempotency-Key makes the retry safe.
      if (attempt >= attempts) throw networkError;
      await sleep(backoff(attempt));
      continue;
    }

    const body = await readJson(response);
    if (response.ok) return body;

    const error = body?.error ?? { code: "INTERNAL_ERROR", message: `HTTP ${response.status}` };
    const retryable =
      RETRYABLE_STATUS.has(response.status) ||
      (error.code === "ENGINE_ERROR" && attempt === 1) ||
      // Same key still running: wait for Retry-After, then get its stored response.
      (error.code === "IDEMPOTENCY_CONFLICT" && response.headers.has("Retry-After"));
    if (!retryable || attempt >= attempts) throw new DcisionError(response.status, error);

    const retryAfter = Number(response.headers.get("Retry-After"));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
  }
}

async function readJson(response) {
  try {
    return await response.json();
  } catch {
    return null; // e.g. an HTML error page from a proxy
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoff = (attempt) => Math.min(8_000, 250 * 2 ** attempt) * (0.5 + Math.random() / 2);
usage
import { DcisionError, decide } from "./dcision.js";

try {
  const decision = await decide("lead-qualification", { message: lead.message, company_size: lead.size }, {
    idempotencyKey: `lead-${lead.id}`,
  });
  await act(decision);
} catch (error) {
  if (error instanceof DcisionError && error.code === "INVALID_STATE") return reportBadInput(lead, error.message);
  await escalate(lead, error); // retries exhausted or a non-retryable error
}

Python

dcision.py
import os
import random
import time
import uuid

import requests

API = "https://api.dcision.io/v1/decisions"
RETRYABLE_STATUS = {429, 500, 503, 504}


class DcisionError(Exception):
    def __init__(self, status, error):
        super().__init__(f"{error['code']}: {error['message']}")
        self.status = status
        self.code = error["code"]
        self.request_id = error.get("request_id")
        self.details = error.get("details")


def decide(slug, state, idempotency_key=None, attempts=4, timeout=10):
    """Runs a decision with retries. Pass a stable idempotency_key (e.g. the record's ID) per operation."""
    key = idempotency_key or str(uuid.uuid4())
    for attempt in range(1, attempts + 1):
        try:
            response = requests.post(
                f"{API}/{slug}",
                headers={
                    "Authorization": f"Bearer {os.environ['DCISION_API_KEY']}",
                    "Idempotency-Key": key,
                },
                json={"state": state},
                timeout=timeout,
            )
        except requests.RequestException:
            # The decision may or may not have run: the same Idempotency-Key makes the retry safe.
            if attempt == attempts:
                raise
            time.sleep(_backoff(attempt))
            continue

        try:
            body = response.json()
        except ValueError:
            body = None  # e.g. an HTML error page from a proxy
        if response.ok:
            return body

        error = (body or {}).get("error") or {"code": "INTERNAL_ERROR", "message": f"HTTP {response.status_code}"}
        retryable = (
            response.status_code in RETRYABLE_STATUS
            or (error["code"] == "ENGINE_ERROR" and attempt == 1)
            # Same key still running: wait for Retry-After, then get its stored response.
            or (error["code"] == "IDEMPOTENCY_CONFLICT" and "Retry-After" in response.headers)
        )
        if not retryable or attempt == attempts:
            raise DcisionError(response.status_code, error)

        retry_after = response.headers.get("Retry-After")
        time.sleep(float(retry_after) if retry_after else _backoff(attempt))


def _backoff(attempt):
    return min(8.0, 0.25 * 2**attempt) * (0.5 + random.random() / 2)
usage
from dcision import DcisionError, decide

try:
    decision = decide(
        "support-routing",
        {"message": ticket["message"], "plan": ticket["plan"]},
        idempotency_key=f"ticket-{ticket['id']}",
    )
    act(decision)
except DcisionError as error:
    escalate(ticket, error)

What not to do

  • Don't generate a new Idempotency-Key per attempt — retries would run, and be billed, again.
  • Don't race your own retries. Two requests sent at the same time with the same key never both run — the second gets 409 with Retry-After: 1 — but it costs a round trip and a rate-limit slot. Retry after a failure or a timeout.
  • Don't retry 4xx errors unchanged (except 429): INVALID_STATE, DECISION_DISABLED, CREDITS_EXHAUSTED or QUOTA_EXCEEDED won't fix themselves.
  • Don't treat IDEMPOTENCY_CONFLICT without Retry-After as transient. It means two different requests share a key — a bug in how keys are built. With Retry-After, it only means the first request is still running.

With the engine-error fallback

A decision whose onEngineError is fallback answers 200 even when the engine fails, with action_reason.type = "engine_error". The client above returns it like any other answer. If you'd rather have real answers when they're cheap to wait for, retry it later with the same key: fallback answers are neither billed nor stored for idempotency, so the retry reaches the engine again — and fires the decision's destinations again, with new delivery IDs.

const decision = await decide("lead-qualification", state, { idempotencyKey: `lead-${lead.id}` });
if (decision.action_reason.type === "engine_error") queue.retryLater({ lead, idempotencyKey: `lead-${lead.id}` });

The CLI's dcision decide follows the same rules: it generates an Idempotency-Key when you don't pass one and retries once on network errors.

On this page