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
- One
Idempotency-Keyper 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. - 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. - 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. - Retry only what can succeed later: network errors,
429,500,503,504,409 IDEMPOTENCY_CONFLICTwithRetry-After— the first request with the key is still running — and502 ENGINE_ERRORonce. Fix everything else. - Honor
Retry-Afteron429and409; otherwise back off exponentially with jitter. - Cap the attempts, then take your error path — usually the same as
escalate.
JavaScript
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);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
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)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-Keyper 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
409withRetry-After: 1— but it costs a round trip and a rate-limit slot. Retry after a failure or a timeout. - Don't retry
4xxerrors unchanged (except429):INVALID_STATE,DECISION_DISABLED,CREDITS_EXHAUSTEDorQUOTA_EXCEEDEDwon't fix themselves. - Don't treat
IDEMPOTENCY_CONFLICTwithoutRetry-Afteras transient. It means two different requests share a key — a bug in how keys are built. WithRetry-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.
Overview and calibration
Read a decision's Overview tab — runs, success rate, latency, cost, actions, reasons and per-question statistics — and act on every calibration suggestion, with the thresholds behind it.
Plans, quotas and billing
Genesis, Developer, Growth and Enterprise — included decisions, prepaid credits past them, rate limits, decision and log limits — what is billed, and how credits, upgrades, downgrades, cancellations and invoices work.