Skip to content
Discord

Errors

Every v2 error — validation, auth, billing, rate limiting — uses one envelope. There is no second shape to special-case.

{
  "error": {
    "code": "invalid_request",
    "message": "to must be an E.164 phone number, got '9876543210'"
  }
}
FieldDescription
codeStable, snake_case, machine-readable. Branch on this.
messageHuman-readable, written for a developer reading a log. Not stable — never parse it, never show it to an end user verbatim.
StatusMeaningRetry?
400The request is malformed or a field is invalid.No — fix the request.
401Missing, malformed or unknown key.No.
402Wallet cannot cover the call.After topping up.
403The key is valid but revoked. That is the only thing that returns 403.No.
404No such resource in your workspace — including one that exists in someone else’s.No.
409State conflict — a request with the same Idempotency-Key still in flight, or an operation the current state forbids.No.
429Request-rate limit. Concurrency does not 429.Yes, with backoff.
500Our fault.Yes, with backoff.
501The feature exists in the contract but is not live yet.No.
503Temporarily unavailable (deploy, capacity).Yes, with backoff.
error.codeStatusWhenFix
invalid_request400Missing required field, wrong type, to not E.164, max_duration_secs out of range, unknown voice_id, malformed JSON.Read message; it names the field.
unauthorized401No Authorization header, not Bearer -prefixed, or the key is unknown.Check the header. A trailing newline from cat-ing a key file is the usual culprit.
forbidden403The key was revoked. Nothing else returns 403.Get a new key.
insufficient_balance402Pre-dial wallet gate. No call was placed and nothing was charged.Top up and retry with the same Idempotency-Key.
not_found404Unknown agt_/call_ ID, a soft-deleted agent, or an ID that belongs to another workspace.Check the ID.
duplicate_call409A request with this Idempotency-Key is still being processed; when the original completes, the same key replays its stored response.Not an error if you are retrying — no second call was placed.
conflict409The operation is illegal in the current state, e.g. aborting a call that has already ended. A queued, dialing or in_progress call aborts fine.Read the call’s status first.
rate_limited429Request rate exceeded, or your queue is full (500 calls accepted and not yet ended). Being at your concurrency ceiling does not produce this: those calls queue. error.message says which limit you hit.Honour Retry-After and back off. See Limits.
tier_unavailable501tier was t3 or t5. Those are announced but not live.Use t1. See tiers.
internal500Our bug. Already alarming on our side.Retry with backoff; if it persists, send us the call_id.

The pattern that covers everything: branch on status, then on code.

import random
import time

import httpx


class MiraiError(Exception):
    def __init__(self, status: int, code: str, message: str):
        super().__init__(f"{status} {code}: {message}")
        self.status, self.code, self.message = status, code, message


RETRYABLE = {429, 500, 502, 503, 504}


def request(client: httpx.Client, method: str, path: str, **kw) -> dict:
    for attempt in range(5):
        r = client.request(method, path, **kw)
        if r.is_success:
            return r.json() if r.content else {}

        body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
        err = body.get("error", {})

        if r.status_code in RETRYABLE and attempt < 4:
            # honour Retry-After when we send it, else exponential + jitter
            wait = float(r.headers.get("Retry-After", 2**attempt))
            time.sleep(wait + random.random())
            continue

        raise MiraiError(r.status_code, err.get("code", "unknown"), err.get("message", r.text))

    raise MiraiError(r.status_code, "retries_exhausted", "gave up after 5 attempts")

At the call site, only two codes deserve their own branch:

try:
    call = request(client, "POST", "/v2/calls", json=payload,
                   headers={"Idempotency-Key": key})
except MiraiError as e:
    if e.code == "insufficient_balance":
        pause_campaign(); alert_ops(e.message)
    elif e.code == "duplicate_call":
        pass                     # same key still in flight — no second call
    else:
        raise
  • Retry 429, 500, 502, 503, 504 and network errors. Exponential backoff with jitter; honour Retry-After when present.
  • Never retry 400, 401, 403, 404, 501 — the same request will fail the same way.
  • 402 is retryable only after a top-up, not on a timer.
  • Always send Idempotency-Key on POST /v2/calls. A timeout tells you nothing about whether the phone rang; without the key, your retry is a second call to a real person.

It means a request with this Idempotency-Key is still being processed. It is the narrow race, not the normal retry path: we cannot replay a response that has not been produced yet, and we must not place a second call, so we say so.

When the original request completes, the same key replays its stored response — so a retry a moment later returns the original 202 and the original call_id. Either way no second call is placed. That is usually the success path of a retry, not a failure: log it and move on, do not surface it as an error to your users.