The error envelope

Most error responses look like:
{
  "message":       "error.auth.invalid_credentials",
  "error_code":    "INVALID_CREDENTIALS",
  "error_subcode": null,
  "validation_errors": [
    { "code": "..." }
  ]
}
FieldWhen presentNotes
messageAlwaysDotted-path i18n key, always starts with error..
error_codeSometimesStable machine code for grouping/logging.
error_subcodeSometimesFurther qualification.
validation_errors[]On 400 from validating endpointsPer-field or per-row codes.
Some endpoints return success-shaped responses with a non-empty validation_errors array (notably file uploads). Always check both the status code and the body.

Status codes

StatusMeaningAction
200204Success. 204 No Content is common for state-change endpoints — there is no body.Trust the success.
400 Validation ErrorThe request is malformed or fails server-side validation.Surface validation_errors[] or message. Don’t retry.
401Auth missing/invalid/expired.Refresh token if proactive failed, else force re-auth.
403Authorised but not allowed (wrong partner, missing permission).Check X-Preferred-Partner-Id and GET /permissions.
404Resource not found.For lists: empty state. For detail: redirect.
409Conflict (concurrent edit, duplicate).Refresh state and try again.
422Semantically invalid (often the CR API on save).Surface validation_errors.
429Rate limited.Back off — see Rate limits.
5xxServer error.Retry with backoff. Log x-request-id.

Mapping error.<code> to user-facing copy

The message field is an i18n key. Map it to your own copy:
const ERROR_MESSAGES: Record<string, string> = {
  "error.auth.invalid_credentials":   "Wrong email or password.",
  "error.auth.account_disabled":      "This account is disabled.",
  "error.auth.mfa_required":          "Enter the code from your authenticator app.",
  "error.event.not_found":            "We couldn't find that event.",
  "error.campaign.setup_invalid":     "Your campaign setup is missing required fields.",
  "error.package.generation_failed":  "Audience generation failed — try regenerating.",
  // ... your dictionary
};

export function humanError(raw: unknown): string {
  const message = (raw as any)?.response?.data?.message
    ?? (raw as any)?.message
    ?? "";
  return ERROR_MESSAGES[message] ?? "Something went wrong. Please try again.";
}
Keep the fallback generic — never leak error.internal.unspecified to a user.

A typed error wrapper

export type FdError = {
  status:    number;
  code?:     string;             // error_code if present
  subcode?:  string;             // error_subcode if present
  message:   string;             // error.<...> key
  validationErrors?: { code: string; field?: string }[];
  requestId?: string;            // x-request-id header
};

export async function fdRequest<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
  const resp = await fetch(input, init);
  if (resp.ok) return resp.json();

  const body = await resp.json().catch(() => ({}));
  const error: FdError = {
    status:           resp.status,
    code:             body.error_code,
    subcode:          body.error_subcode,
    message:          body.message ?? `http.${resp.status}`,
    validationErrors: body.validation_errors,
    requestId:        resp.headers.get("x-request-id") ?? undefined,
  };
  throw error;
}
Throw structured errors, not strings. Your UI layer becomes much cleaner.

Retry with exponential backoff

async function withRetry<T>(
  fn: () => Promise<T>,
  { attempts = 3, baseMs = 500 }: { attempts?: number; baseMs?: number } = {},
): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (e) {
      const err = e as FdError;
      const retryable =
        err.status >= 500
        || err.status === 429
        || err.message === "network_error";
      if (!retryable || i === attempts - 1) throw e;
      await sleep(baseMs * 2 ** i + Math.random() * 200);
    }
  }
  throw new Error("unreachable");
}
Do not retry 400 / 401 / 403 / 404 / 422. They are not transient; they indicate a bug in your request or session state.

Logging and support

Always log the x-request-id response header on any failure:
catch (e) {
  log.error("fd_call_failed", {
    path,
    status:    e.status,
    code:      e.code,
    message:   e.message,
    requestId: e.requestId,
  });
}
Include it in any support ticket. It lets Future Demand trace the exact call across services in seconds.

Surfacing validation errors in forms

For 400 with validation_errors[], map field-level codes back to inputs:
function applyValidationErrors(
  setError: (field: string, msg: string) => void,
  errs: { code: string; field?: string }[],
) {
  for (const e of errs) {
    if (!e.field) continue;
    setError(e.field, ERROR_MESSAGES[`error.field.${e.code}`] ?? "Invalid value");
  }
}
If field isn’t present, surface a form-level error instead. Don’t drop the response on the floor.

Common mistakes

Wastes calls and can violate idempotency for non-GET endpoints. Retry only on 5xx, 429, and network errors.
The dotted keys are for machines. Always map to human-readable copy through your i18n layer.
Aborted fetches throw AbortError. They are expected on filter changes and unmounts — swallow them silently.
Many state-change endpoints (e.g. PUT /events/{eid}/setup_frontend_status, PUT /events/{eid}/init_campaigns_setup) return 204 No Content. Your client must accept a body-less 2xx as success.