Error Handling
The FOTOhub API uses conventional HTTP status codes and returns structured JSON error responses for every failure. This page covers all error codes, the response format, retry strategies, idempotency, and best practices for building resilient integrations.
SDK Auto-Handling
The official SDKs (Python and TypeScript) classify errors automatically, retry transient failures with exponential backoff, and raise typed exceptions. If you are building a new integration, start with the SDK for the smoothest experience.
Error Response Format
Read the status code and detail, not just the fields below
Most endpoints return a compact body — {"detail": "prompt is required"} — and the rate limiter returns {"error": "Rate limit exceeded. Please try again later."}. Treat error, message and details as optional and branch on the HTTP status code, which is always meaningful. For correlating a call with support, use the X-Request-Id response header, which is present on every response — see Request ID for Support. The body carries request_id only on a 500.
Where a richer body is returned, the whole of it sits inside detail — the framework puts it there, so error and message are one level down, not at the top. Use error for programmatic handling and message for user-facing display.
{
"detail": {
"error": "insufficient_funds",
"message": "Insufficient funds: this request costs $0.045000 but your balance is $0.002000. Top up your wallet with at least $0.043000 to continue. The FOTOhub API is prepaid: no credits or subscription plan can pay for API usage.",
"required_usd": 0.045,
"balance_usd": 0.002,
"shortfall_usd": 0.043,
"currency": "USD",
"charged": false,
"topup_url": "https://fotohub.app/console/wallet"
}
}Response Fields
| Field | Type | Always Present | Description |
|---|---|---|---|
detail | string | object | No | The only field most endpoints return. A string for simple failures; an object carrying error/message and extra context for the richer ones (see the 429 bodies). Check its type before indexing into it. |
error | string | No | Machine-readable error code in snake_case, where present. Use for switch/match statements. |
message | string | No | Human-readable description. Safe to display to end users. |
details | object | No | Additional context — varies by error code. May include limits, field names, or URLs. |
request_id | string | No | Our identifier for the call, returned in the body on 500 responses. Present as the X-Request-Id header on all responses; prefer the header. |
HTTP Status Codes
The API uses standard HTTP status codes to indicate the outcome of a request. Codes in the 2xx range indicate success, 4xx indicate client errors, and 5xx indicate server-side failures.
| Status | Name | Description | Retryable |
|---|---|---|---|
| 200 | OK | Request succeeded. Response body contains the result. | No |
| 201 | Created | Resource successfully created (e.g., new API key, project). | No |
| 400 | Bad Request | Invalid parameters, malformed JSON, or missing required fields. | No |
| 401 | Unauthorized | Missing or invalid API key in the Authorization header. | No |
| 402 | Payment Required | Your prepaid wallet cannot cover this request. Carries the price, your balance and the shortfall — see 402 Payment Required. | No |
| 403 | Forbidden | API key does not have permission for this resource or action. | No |
| 404 | Not Found | The endpoint or requested resource does not exist. | No |
| 409 | Conflict | Resource already exists (e.g., duplicate project name). | No |
| 413 | Payload Too Large | Request body or uploaded file exceeds the maximum allowed size. | No |
| 422 | Unprocessable Entity | Valid JSON but semantically invalid (e.g., negative duration). | No |
| 429 | Too Many Requests | Rate limit exceeded. Check Retry-After header. | Yes |
| 500 | Internal Server Error | Unexpected server-side failure. Please report with request_id. | Yes |
| 502 | Bad Gateway | Upstream AI provider timed out or returned invalid response. | Yes |
| 503 | Service Unavailable | Model or service temporarily down for maintenance. | Yes |
| 504 | Gateway Timeout | Request exceeded the maximum allowed processing time. | Yes |
Error Codes Reference
Machine-readable error codes, grouped by what they refuse. Every code below appears inside detail, alongside a human-readable message and whatever context is useful for recovery — limit and current on a plan gate, valid_tiers on a bad tier, and so on.
Not every failure has a code
Many endpoints answer with a plain string — {"detail": "prompt is required"} — so branch on the HTTP status first and treat detail.error as extra detail when it happens to be an object.
402 is the exception, and the one worth handling precisely: every out-of-funds refusal across the whole API is an object carrying error: "insufficient_funds" and the amounts behind it. See 402 Payment Required.
Authentication and Access
| Error Code | HTTP Status | Description | Recovery Action |
|---|---|---|---|
authentication_required | 401 | No Authorization header. Every endpoint requires one. | Send Authorization: Bearer fh_live_…. |
api_access_required | 403 | The account has no API entitlement (reason says which). | Subscribe to an API plan in the console. |
email_not_verified | 403 | Email not confirmed, and a purchase was attempted. | Confirm the address from the inbox, then retry. |
account_suspended | 403 | The account is suspended. | Contact support. |
account_too_new | 403 | Wallet top-ups require the account to be at least an hour old. | Wait, then retry. |
entitlement_check_unavailable | 503 | We could not verify entitlement — our side, not yours. | Retry with backoff. |
Billing, Plans and Tiers
| Error Code | HTTP Status | Description | Recovery Action |
|---|---|---|---|
insufficient_funds | 402 | The wallet cannot pay for this request. Carries required_usd, balance_usd, shortfall_usd, charged: false, topup_url. | Top up by at least shortfall_usd. |
insufficient_funds (S3 bucket) | 402 | The wallet cannot cover the bucket's up-front reservation. Same fields, plus a hint. | Top up, or create the bucket with billing_mode: "invoice_monthly". |
insufficient_funds (storage accrual) | 402 | The wallet is empty and the endpoint bills by accrual, not per request — see billed: "hourly_storage". Carries balance_usd but no required_usd or shortfall_usd, because there is no single amount to quote. | Top up any amount. Branch on error, never on required_usd. |
plan_gate_exceeded | 402 | Your plan's cap for this resource. Carries current, limit, tier. | Upgrade, or delete an existing resource. |
key_limit_reached | 400 | Maximum API keys for the tier. Carries max_keys, upgrade_url. | Revoke a key or upgrade. |
feature_not_available | 403 | Feature not on this tier. Carries required_tiers. | Upgrade to one of required_tiers. |
model_not_available | 403 | The model is not on your tier. Carries tier, model. | Use a permitted model or upgrade. |
invalid_tier | 400 | Unknown tier slug. Carries valid_tiers. | Pick one of valid_tiers. |
invalid_upgrade | 400 | That upgrade path does not exist. Carries valid_upgrades. | Pick one of valid_upgrades. |
already_subscribed | 400 | Already on the requested tier. | Nothing to do. |
enterprise_only | 400 | Enterprise is by application. | POST /v1/tiers/enterprise/apply. |
application_pending | 400 | An enterprise application is already open. | Wait for the decision. |
Rate Limiting
| Error Code | HTTP Status | Description | Recovery Action |
|---|---|---|---|
rate_limit_exceeded | 429 | Too many requests. Three limiters can produce it — see Rate limits. | Wait for the Retry-After header, then retry. |
Output Routing and Destinations
Only relevant if you route generations to your own bucket.
| Error Code | HTTP Status | Description | Recovery Action |
|---|---|---|---|
missing_fields | 400 | An external destination is missing required fields. Carries fields. | Supply everything in fields. |
credentials_not_allowed | 400 | Credentials were sent for a destination kind that manages its own. | Drop the credentials. |
bucket_id_required | 400 | A console-managed destination needs bucket_id. | Send the bucket id. |
account_id_required | 400 | The chosen preset needs an account id. | Send the account id. |
invalid_path_template | 400 | Bad path template. Carries allowed_tokens and an example. | Use only allowed_tokens. |
too_many_rules | 400 | Per-key routing-rule cap. Carries limit. | Delete a rule first. |
pattern_already_routed | 409 | The key already has a rule for that pattern. | Update the existing rule. |
bucket_not_found | 404 | No such bucket on this account. | Check the id in the console. |
destination_in_use | 409 | Still attached to keys. Carries keys. | Detach it from those keys first. |
destination_create_failed | 409 | Creation failed — usually a duplicate name. | Pick another name. |
update_failed | 409 | The update conflicted. | Re-read the destination and retry. |
Everything else
Failures outside these groups — invalid parameters, unsupported formats, provider timeouts, generation errors — return a plain detail string with the appropriate status code and no error code. 422 bodies come from request validation and carry FastAPI's own detail array of per-field errors, which is worth logging verbatim.
Example Error Responses
Live shapes, captured against the production API. The status code is the reliable signal; detail is a string for most failures and an object for the ones that carry context.
400 Bad Request — a hand-checked parameter:
{ "detail": "prompt is required" }401 Unauthorized — no credential, or one we do not recognise:
{ "detail": "Missing Authorization header" }{ "detail": "Invalid API key" }402 Payment Required — out of money. Every out-of-funds refusal in the API looks like this, and every amount in it is USD. Full treatment in 402 Payment Required:
{
"detail": {
"error": "insufficient_funds",
"message": "Insufficient funds: this request costs $0.045000 but your balance is $0.002000. Top up your wallet with at least $0.043000 to continue. The FOTOhub API is prepaid: no credits or subscription plan can pay for API usage.",
"required_usd": 0.045,
"balance_usd": 0.002,
"shortfall_usd": 0.043,
"currency": "USD",
"charged": false,
"topup_url": "https://fotohub.app/console/wallet"
}
}Resource caps in the same status carry a different code, and topping up will not clear them:
{
"detail": {
"error": "plan_gate_exceeded",
"message": "Your plan (developer) allows up to 3 S3 buckets",
"current": 3,
"limit": 3,
"tier": "developer"
}
}403 Forbidden — a tier gate. required_tiers tells you what would clear it:
{
"detail": {
"error": "feature_not_available",
"message": "Feature 'output_routing' is not available on the developer tier",
"tier": "developer",
"required_tiers": ["startup", "business", "enterprise"]
}
}404 Not Found — note that an unknown path returns the same shape as a missing resource, so a typo in the URL and a deleted object are indistinguishable from the body alone:
{ "detail": "Not Found" }409 Conflict:
{
"detail": {
"error": "pattern_already_routed",
"message": "This key already has a rule for 'video/*'"
}
}422 Unprocessable Entity — request-model validation. detail is an array, one entry per offending field. Log it verbatim; it is the most specific error we return:
{
"detail": [
{ "type": "missing", "loc": ["body", "name"], "msg": "Field required", "input": { "kind": 123 } },
{ "type": "string_type", "loc": ["body", "kind"], "msg": "Input should be a valid string", "input": 123 }
]
}429 Too Many Requests — the body depends on which limiter fired, so read the Retry-After header rather than the payload. All three shapes are listed under 429 Too Many Requests.
{ "error": "Rate limit exceeded. Please try again later." }500 Internal Server Error — deliberately generic, because exception text can carry credentials. This is the one status that also puts the id in the body, since a bare "Internal server error" with nothing to quote is unactionable:
{
"detail": "Internal server error",
"request_id": "1878ab43-df35-460d-9336-9cc80d60c559"
}502 / 503 / 504 — an upstream provider failed, timed out, or is unreachable. These carry whatever the provider path reported, as a string:
{ "detail": "Provider request failed" }Retry these with backoff and quote the X-Request-Id; the request log holds the provider's own id for the same call.
402 Payment Required
The API is prepaid. It charges your wallet in USD per request, and nothing else can pay for one: no subscription plan, no credit balance from your fotohub.app account, no invoice at month end. A wallet that cannot cover a request gets a 402 before the provider is called, so a refusal costs nothing.
Every out-of-funds refusal, on every endpoint, carries error: "insufficient_funds" and the amounts behind it:
{
"detail": {
"error": "insufficient_funds",
"code": "insufficient_funds",
"message": "Insufficient funds: this request costs $0.045000 but your balance is $0.002000. Top up your wallet with at least $0.043000 to continue. The FOTOhub API is prepaid: no credits or subscription plan can pay for API usage.",
"required_usd": 0.045,
"balance_usd": 0.002,
"shortfall_usd": 0.043,
"currency": "USD",
"charged": false,
"charged_usd": 0,
"topup_url": "https://fotohub.app/console/wallet",
"operation": "generate_image:seedream-5-0-pro"
}
}| Field | Meaning |
|---|---|
error / code | Always insufficient_funds. Both keys carry the same value; error is the one to switch on. |
message | The complete explanation, safe to show a user. Always present, even when the amounts are not. |
required_usd | What this request would have cost, at the price you would have been charged. |
balance_usd | Your wallet at the moment of refusal. 0 is a real value — an empty wallet, not a missing field. |
shortfall_usd | required_usd - balance_usd: the minimum top-up that makes this exact request go through. |
charged / charged_usd | Always false / 0. Stated rather than implied — a 402 moves no money and calls no provider. |
topup_url | Where to top up. Link it rather than hardcoding a path. |
operation | The priced operation, usually <action>:<model>. Worth logging: it says which model drained the wallet. |
Because nothing was charged, the request can be retried unchanged once the wallet is funded — no idempotency key needed, and no partial state to clean up.
The storage exception
The storage endpoints bill by hourly accrual against the bytes you hold, not per request, so there is no single amount to quote. Their 402 carries billed: "hourly_storage", a balance, and no required_usd or shortfall_usd:
{
"detail": {
"error": "insufficient_funds",
"message": "Insufficient funds: your wallet balance is $0.00. Storage is billed from the wallet every hour for the bytes you hold, so an empty wallet cannot accept new uploads. Top up your wallet to continue. The FOTOhub API is prepaid: no credits or subscription plan can pay for API usage.",
"balance_usd": 0,
"currency": "USD",
"charged": false,
"topup_url": "https://fotohub.app/console/wallet",
"billed": "hourly_storage"
}
}So branch on error, treat the amounts as optional, and decode them into a nullable type. A float that turns an absent required_usd into 0.0 reports a free request; an empty string coerced the same way reports an empty wallet. Both are wrong, and both look like data.
Check before you spend
POST /v1/billing/estimate prices up to 100 operations without running any of them, so a batch job can stop at the last affordable item instead of collecting a 402:
curl -s -X POST https://apis.fotohub.app/v1/billing/estimate \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"operations": [{"type": "generate_image", "model": "seedream-5-0-260128", "count": 40}]}'{
"currency": "USD",
"billing_model": "prepaid_wallet_usd",
"total_usd": 1.50052,
"provider_cost_usd": 1.50052,
"margin": 1.0,
"balance_usd": 0.9,
"sufficient": false,
"priced": true,
"breakdown": [
{
"type": "generate_image",
"model": "seedream-5-0-260128",
"count": 40,
"priced": true,
"unit": "per_piece",
"amount_usd": 1.50052,
"provider_cost_usd": 1.50052,
"pricing_verified": true,
"breakdown": [
{ "leg": "output", "unit": "per_piece", "quantity": 40, "rate_usd": 0.037513, "amount_usd": 1.50052 }
]
}
]
}Prefer the server's sufficient verdict to comparing the numbers yourself — it is computed against the same wallet read the charge will use. Two things to know about it:
- An operation with no published rate comes back
priced: falsewithamount_usd: nulland areason.total_usdthen covers only the priced legs and top-levelpricedisfalse, sosufficient: trueon that response means "the wallet is not empty", not "this batch is covered". total_creditsis present and alwaysnull. It is a deprecated field kept for older SDK builds; there is no credit unit in the API. Readtotal_usd.
You also do not have to poll the balance: every billed response carries billing.balance_usd, the wallet after that charge, so a client can warn its user while there is still money left.
For the wallet itself — reading the balance, top-up packages, spend history — see Billing.
Retry Strategies
Implement exponential backoff with jitter for transient errors (429, 500, 502, 503, 504). Never retry 4xx errors other than 429 — they indicate a problem with the request itself that must be fixed before retrying.
Exponential Backoff Algorithm
delay = min(base_delay * 2^attempt + random_jitter, max_delay)
Example sequence:
Attempt 1: ~1.0s (1 * 2^0 + jitter)
Attempt 2: ~2.3s (1 * 2^1 + jitter)
Attempt 3: ~4.7s (1 * 2^2 + jitter)
Attempt 4: ~8.1s (1 * 2^3 + jitter)
Attempt 5: ~16.5s (1 * 2^4 + jitter, capped at max_delay)
Recommended defaults:
base_delay: 1 second
max_delay: 30 seconds
max_retries: 3-5
jitter: 0 to 1 second (uniform random)Retry Decision Matrix
| Scenario | Action | Delay Strategy |
|---|---|---|
| 429 with Retry-After header | Retry | Use header value + small jitter |
| 429 without Retry-After | Retry | Exponential backoff (start at 1s) |
| 500 Internal Server Error | Retry | Exponential backoff (start at 2s) |
| 502 Bad Gateway | Retry | Exponential backoff (start at 5s) |
| 503 Service Unavailable | Retry | Exponential backoff (start at 5s) |
| 504 Gateway Timeout | Retry | Exponential backoff (start at 5s) |
409 on a request carrying X-Idempotency-Key | Retry | Use Retry-After; your own earlier attempt is still in flight |
| 400/401/402/403/404/409/422 | Do NOT retry | Fix request and resubmit |
| Network timeout | Retry | Exponential backoff + use idempotency key |
The two 409 rows are not a contradiction: with an idempotency key a 409 means "the request you already sent with this key is still running", so waiting collects its result. Without a key it is an ordinary conflict and retrying it changes nothing. See Idempotency.
Implementation — Retry with Exponential Backoff
import time
import random
import requests
API_BASE = "https://apis.fotohub.app/v1"
API_KEY = "fh_live_your_api_key"
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
class FotohubAPIError(Exception):
"""Structured error from the FOTOhub API."""
def __init__(self, status: int, error: str, message: str, request_id: str, details: dict = None):
super().__init__(message)
self.status = status
self.error = error
self.request_id = request_id
self.details = details or {}
def request_with_retry(
method: str,
path: str,
json_body: dict = None,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
) -> dict:
"""
Make an API request with automatic retry on transient errors.
Respects Retry-After header for 429 responses.
Does NOT retry client errors (400, 401, 402, 403, 404, 409, 422).
"""
url = f"{API_BASE}{path}"
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
for attempt in range(max_retries + 1):
try:
response = requests.request(
method, url, headers=headers, json=json_body, timeout=60
)
if response.status_code < 400:
return response.json()
error_body = response.json()
# Everything the API reports about a failure sits under `detail`,
# which is a string on most endpoints and an object on the ones that
# carry context. Flatten both into the same three fields.
detail = error_body.get("detail")
if isinstance(detail, dict):
error_code = detail.get("error", "unknown")
message = detail.get("message") or str(detail)
details = detail
else:
error_code = "unknown"
message = detail if isinstance(detail, str) else "Unknown error"
details = {}
# `X-Request-Id` is on every response; the body only carries it on 500.
request_id = response.headers.get("X-Request-Id", "")
# Non-retryable error — raise immediately
if response.status_code not in RETRYABLE_STATUS_CODES:
raise FotohubAPIError(
response.status_code, error_code, message, request_id, details
)
# Retryable error — check if we have retries left
if attempt == max_retries:
raise FotohubAPIError(
response.status_code, error_code, message, request_id, details
)
# Calculate delay with exponential backoff + jitter
if response.status_code == 429:
# Prefer server-provided Retry-After
delay = float(response.headers.get("Retry-After", base_delay * (2 ** attempt)))
else:
delay = min(base_delay * (2 ** attempt), max_delay)
jitter = random.uniform(0, 1)
total_delay = delay + jitter
print(
f"[{error_code}] Retry {attempt + 1}/{max_retries} in {total_delay:.1f}s "
f"(request_id: {request_id})"
)
time.sleep(total_delay)
except requests.exceptions.Timeout:
if attempt == max_retries:
raise
delay = min(base_delay * (2 ** attempt), max_delay) + random.uniform(0, 1)
time.sleep(delay)
raise Exception(f"Max retries ({max_retries}) exceeded for {path}")
# Usage
try:
result = request_with_retry("POST", "/ai/generate/image", {
"model": "seedream-5-0-260128",
"prompt": "A serene mountain landscape at golden hour",
"width": 1024,
"height": 1024,
})
print(f"Generated: {result['url']}")
except FotohubAPIError as e:
if e.error == "insufficient_funds":
print(f"{e} — top up at {e.details.get('topup_url')}")
elif e.error == "invalid_parameters":
print(f"Bad request: {e} — fields: {e.details.get('fields', [])}")
elif e.error == "model_unavailable":
print(f"Model offline: {e} — try a fallback model")
else:
print(f"API error [{e.error}]: {e} (request_id: {e.request_id})")const API_BASE = "https://apis.fotohub.app/v1";
const API_KEY = "fh_live_your_api_key";
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
// `detail` is a string on most endpoints and an object on the ones that carry
// context (402, 403 gates, 409). A 422 makes it an array, one entry per field.
interface FotoHubErrorBody {
detail?: string | Record<string, unknown> | unknown[];
}
class FotohubAPIError extends Error {
status: number;
code: string;
requestId: string;
details: Record<string, unknown>;
constructor(status: number, body: FotoHubErrorBody, requestId: string) {
const detail = body.detail;
const isObject =
typeof detail === "object" && detail !== null && !Array.isArray(detail);
const fields = isObject ? (detail as Record<string, unknown>) : {};
super(
(typeof fields.message === "string" && fields.message) ||
(typeof detail === "string" ? detail : JSON.stringify(detail ?? "Unknown error"))
);
this.name = "FotohubAPIError";
this.status = status;
this.code = typeof fields.error === "string" ? fields.error : "unknown";
// The header is on every response; the body only carries an id on 500.
this.requestId = requestId;
this.details = fields;
}
}
interface RetryOptions {
maxRetries?: number;
baseDelay?: number;
maxDelay?: number;
}
async function requestWithRetry<T>(
method: string,
path: string,
body?: Record<string, unknown>,
options: RetryOptions = {}
): Promise<T> {
const { maxRetries = 3, baseDelay = 1000, maxDelay = 30000 } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(`${API_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (response.ok) {
return response.json() as Promise<T>;
}
const errorBody: FotoHubErrorBody = await response
.json()
.catch(() => ({ detail: response.statusText }));
const requestId = response.headers.get("X-Request-Id") ?? "";
// Non-retryable error
if (!RETRYABLE_STATUS_CODES.has(response.status)) {
throw new FotohubAPIError(response.status, errorBody, requestId);
}
// Last attempt — throw
if (attempt === maxRetries) {
throw new FotohubAPIError(response.status, errorBody, requestId);
}
// Calculate delay
let delay: number;
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
delay = retryAfter
? parseFloat(retryAfter) * 1000
: baseDelay * Math.pow(2, attempt);
} else {
delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
}
// Add jitter (0-1000ms)
const jitter = Math.random() * 1000;
const totalDelay = delay + jitter;
console.warn(
`[${response.status}] Retry ${attempt + 1}/${maxRetries} ` +
`in ${(totalDelay / 1000).toFixed(1)}s ` +
`(request_id: ${requestId})`
);
await new Promise((resolve) => setTimeout(resolve, totalDelay));
}
throw new Error("Unreachable");
}
// Usage
try {
const result = await requestWithRetry<{ url: string }>(
"POST",
"/ai/generate/image",
{
model: "seedream-5-0-260128",
prompt: "A serene mountain landscape at golden hour",
width: 1024,
height: 1024,
}
);
console.log(`Generated: ${result.url}`);
} catch (e) {
if (e instanceof FotohubAPIError) {
switch (e.code) {
case "insufficient_funds":
console.error(`${e.message} — top up at ${e.details.topup_url}`);
break;
case "invalid_parameters":
console.error(`Bad request: ${e.message}`, e.details);
break;
case "model_unavailable":
console.error(`Model offline: ${e.message} — try a fallback model`);
break;
default:
console.error(
`API error [${e.code}]: ${e.message} (request_id: ${e.requestId})`
);
}
}
}package main
import (
"bytes"
"encoding/json"
"fmt"
"math"
"math/rand"
"net/http"
"strconv"
"time"
)
const (
apiBase = "https://apis.fotohub.app/v1"
apiKey = "fh_live_your_api_key"
maxRetries = 3
baseDelay = 1 * time.Second
maxDelay = 30 * time.Second
)
// FotohubAPIError represents a structured API error.
type FotohubAPIError struct {
Status int `json:"-"`
Error string `json:"error"`
Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"`
RequestID string `json:"request_id"`
}
func (e *FotohubAPIError) Unwrap() string {
return fmt.Sprintf("[%s] %s (request_id: %s)", e.Error, e.Message, e.RequestID)
}
// Retryable status codes
var retryableStatusCodes = map[int]bool{
429: true, 500: true, 502: true, 503: true, 504: true,
}
func requestWithRetry(method, path string, payload interface{}) (map[string]interface{}, error) {
var bodyBytes []byte
if payload != nil {
var err error
bodyBytes, err = json.Marshal(payload)
if err != nil {
return nil, err
}
}
for attempt := 0; attempt <= maxRetries; attempt++ {
var req *http.Request
var err error
if bodyBytes != nil {
req, err = http.NewRequest(method, apiBase+path, bytes.NewReader(bodyBytes))
} else {
req, err = http.NewRequest(method, apiBase+path, nil)
}
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
// Network error — retry with backoff
if attempt == maxRetries {
return nil, fmt.Errorf("network error after %d retries: %w", maxRetries, err)
}
time.Sleep(calculateBackoff(attempt))
continue
}
defer resp.Body.Close()
// Success
if resp.StatusCode < 400 {
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}
// Parse error body
var apiErr FotohubAPIError
json.NewDecoder(resp.Body).Decode(&apiErr)
apiErr.Status = resp.StatusCode
// Non-retryable client error
if !retryableStatusCodes[resp.StatusCode] {
return nil, fmt.Errorf("API error %d [%s]: %s (request_id: %s)",
apiErr.Status, apiErr.Error, apiErr.Message, apiErr.RequestID)
}
// Last attempt — return error
if attempt == maxRetries {
return nil, fmt.Errorf("API error %d [%s] after %d retries: %s (request_id: %s)",
apiErr.Status, apiErr.Error, maxRetries, apiErr.Message, apiErr.RequestID)
}
// Calculate wait time
var waitTime time.Duration
if resp.StatusCode == 429 {
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
seconds, _ := strconv.ParseFloat(retryAfter, 64)
waitTime = time.Duration(seconds * float64(time.Second))
} else {
waitTime = calculateBackoff(attempt)
}
} else {
waitTime = calculateBackoff(attempt)
}
// Add jitter
jitter := time.Duration(rand.Float64() * float64(time.Second))
totalWait := waitTime + jitter
if totalWait > maxDelay {
totalWait = maxDelay
}
fmt.Printf("[%s] Retry %d/%d in %v (request_id: %s)\n",
apiErr.Error, attempt+1, maxRetries, totalWait, apiErr.RequestID)
time.Sleep(totalWait)
}
return nil, fmt.Errorf("max retries (%d) exceeded for %s", maxRetries, path)
}
func calculateBackoff(attempt int) time.Duration {
delay := float64(baseDelay) * math.Pow(2, float64(attempt))
if delay > float64(maxDelay) {
delay = float64(maxDelay)
}
jitter := rand.Float64() * float64(time.Second)
return time.Duration(delay + jitter)
}
func main() {
result, err := requestWithRetry("POST", "/ai/generate/image", map[string]interface{}{
"model": "seedream-5-0-260128",
"prompt": "A serene mountain landscape at golden hour",
"width": 1024,
"height": 1024,
})
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Generated: %s\n", result["url"])
}#!/bin/bash
# FOTOhub API request with exponential backoff and jitter.
# Retries on 429, 500, 502, 503, 504. Stops on 4xx client errors.
API_BASE="https://apis.fotohub.app/v1"
API_KEY="fh_live_your_api_key"
MAX_RETRIES=3
fotohub_request() {
local method="$1"
local endpoint="$2"
local payload="$3"
local attempt=0
local base_delay=1
while [ $attempt -le $MAX_RETRIES ]; do
# Make the request, capture status code separately
local tmpfile=$(mktemp)
local http_code
http_code=$(curl -s -w "%{http_code}" \
-X "$method" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-D /tmp/fh_headers.txt \
-o "$tmpfile" \
${payload:+-d "$payload"} \
"$API_BASE$endpoint")
local body
body=$(cat "$tmpfile")
rm -f "$tmpfile"
# Success (2xx)
if [ "$http_code" -lt 400 ] 2>/dev/null; then
echo "$body"
return 0
fi
# Non-retryable client errors (400, 401, 402, 403, 404, 409, 413, 422)
if [ "$http_code" -ge 400 ] && [ "$http_code" -lt 429 ] 2>/dev/null; then
echo "Client error HTTP $http_code: $body" >&2
return 1
fi
if [ "$http_code" -gt 429 ] && [ "$http_code" -lt 500 ] 2>/dev/null; then
echo "Client error HTTP $http_code: $body" >&2
return 1
fi
# Retryable (429, 500-504) — check retries left
if [ $attempt -eq $MAX_RETRIES ]; then
echo "Failed after $MAX_RETRIES retries. HTTP $http_code: $body" >&2
return 1
fi
# Calculate wait time
local wait_time
if [ "$http_code" = "429" ]; then
# Prefer Retry-After header
local retry_after
retry_after=$(grep -i "Retry-After:" /tmp/fh_headers.txt \
| awk '{print $2}' | tr -d '\r')
if [ -n "$retry_after" ]; then
wait_time=$retry_after
else
wait_time=$((base_delay * (2 ** attempt)))
fi
else
wait_time=$((base_delay * (2 ** attempt)))
fi
# Add jitter (0-1 second)
local jitter
jitter=$(echo "scale=2; $RANDOM / 32767" | bc)
local total_wait
total_wait=$(echo "$wait_time + $jitter" | bc)
# Cap at 30 seconds
if [ "$(echo "$total_wait > 30" | bc)" -eq 1 ]; then
total_wait=30
fi
local request_id
request_id=$(echo "$body" | jq -r '.request_id // "unknown"')
local error_code
error_code=$(echo "$body" | jq -r '.error // "unknown"')
echo "[$error_code] Retry $((attempt+1))/$MAX_RETRIES in ${total_wait}s (request_id: $request_id)" >&2
sleep "$total_wait"
attempt=$((attempt + 1))
done
echo "Max retries exceeded" >&2
return 1
}
# Usage
result=$(fotohub_request "POST" "/ai/generate/image" '{
"model": "seedream-5-0-260128",
"prompt": "A serene mountain landscape at golden hour",
"width": 1024,
"height": 1024
}')
if [ $? -eq 0 ]; then
echo "Generated: $(echo "$result" | jq -r '.url')"
else
echo "Generation failed"
fiRate Limit Handling
When you receive a 429 response, the server includes a Retry-After header indicating how many seconds to wait before retrying. Always respect this value rather than using arbitrary delays.
Rate Limit Response Headers
| Header | Description |
|---|---|
Retry-After | Number of seconds to wait before retrying. The only header on every 429 |
X-RateLimit-Limit | Maximum number of requests allowed in the current window |
X-RateLimit-Remaining | Number of requests remaining in the current window |
X-RateLimit-Reset | Unix timestamp when the rate limit window resets |
X-Request-Id | Our identifier for this call — quote it in support tickets |
The X-RateLimit-* trio is absent when the per-endpoint limiter fires, because it refuses the request before anything has resolved which key or tier is calling.
Example Rate Limit Response
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
X-Request-Id: ac786e3b-a048-409b-84f2-6e4c95f4984f
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Tier, Retry-After
{ "error": "Rate limit exceeded. Please try again later." }Handling Retry-After
import time
import requests
def handle_rate_limit(response: requests.Response) -> float:
"""
Respect the Retry-After header from rate-limited responses.
Returns the number of seconds waited.
"""
retry_after = response.headers.get("Retry-After")
if retry_after:
wait_seconds = float(retry_after)
print(f"Rate limited. Waiting {wait_seconds}s before retry...")
time.sleep(wait_seconds)
return wait_seconds
else:
# Fallback: exponential backoff starting at 5s
print("Rate limited (no Retry-After). Waiting 5s...")
time.sleep(5)
return 5.0
# Proactive monitoring — throttle before hitting the limit
def check_rate_limit_headers(response: requests.Response) -> bool:
"""
Check rate limit headers and warn if nearing the limit.
Returns True if you should slow down.
"""
remaining = response.headers.get("X-RateLimit-Remaining")
limit = response.headers.get("X-RateLimit-Limit")
if remaining and limit:
ratio = int(remaining) / int(limit)
if ratio <= 0.1:
print(f"WARNING: Only {remaining}/{limit} requests remaining in window")
return True
return Falseasync function handleRateLimit(response: Response): Promise<number> {
/**
* Respect the Retry-After header from rate-limited responses.
* Returns the number of milliseconds waited.
*/
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
const waitMs = parseFloat(retryAfter) * 1000;
console.log(`Rate limited. Waiting ${retryAfter}s before retry...`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
return waitMs;
} else {
// Fallback: 5 second delay
console.log("Rate limited (no Retry-After). Waiting 5s...");
await new Promise((resolve) => setTimeout(resolve, 5000));
return 5000;
}
}
// Proactive monitoring — throttle before hitting the limit
function checkRateLimitHeaders(response: Response): boolean {
const remaining = response.headers.get("X-RateLimit-Remaining");
const limit = response.headers.get("X-RateLimit-Limit");
if (remaining && limit) {
const ratio = parseInt(remaining) / parseInt(limit);
if (ratio <= 0.1) {
console.warn(`WARNING: Only ${remaining}/${limit} requests remaining`);
return true; // Should slow down
}
}
return false;
}package main
import (
"fmt"
"net/http"
"strconv"
"time"
)
// handleRateLimit respects the Retry-After header and blocks until safe to retry.
func handleRateLimit(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
if retryAfter != "" {
seconds, err := strconv.ParseFloat(retryAfter, 64)
if err == nil {
wait := time.Duration(seconds * float64(time.Second))
fmt.Printf("Rate limited. Waiting %v before retry...\n", wait)
time.Sleep(wait)
return wait
}
}
// Fallback: 5 second delay
fmt.Println("Rate limited (no Retry-After). Waiting 5s...")
time.Sleep(5 * time.Second)
return 5 * time.Second
}
// checkRateLimitHeaders returns true if the client should slow down.
func checkRateLimitHeaders(resp *http.Response) bool {
remainingStr := resp.Header.Get("X-RateLimit-Remaining")
limitStr := resp.Header.Get("X-RateLimit-Limit")
if remainingStr != "" && limitStr != "" {
remaining, _ := strconv.Atoi(remainingStr)
limit, _ := strconv.Atoi(limitStr)
if limit > 0 {
ratio := float64(remaining) / float64(limit)
if ratio <= 0.1 {
fmt.Printf("WARNING: Only %d/%d requests remaining in window\n",
remaining, limit)
return true
}
}
}
return false
}#!/bin/bash
# Handle rate limiting from FOTOhub API responses
API_BASE="https://apis.fotohub.app/v1"
API_KEY="fh_live_your_api_key"
# Make a request and check rate limit status
response=$(curl -s -D /tmp/fh_headers.txt \
-H "Authorization: Bearer $API_KEY" \
-w "\n%{http_code}" \
"$API_BASE/ai/models")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" = "429" ]; then
# Extract Retry-After header
retry_after=$(grep -i "Retry-After:" /tmp/fh_headers.txt \
| awk '{print $2}' | tr -d '\r')
if [ -n "$retry_after" ]; then
echo "Rate limited. Waiting ${retry_after}s..." >&2
sleep "$retry_after"
else
echo "Rate limited. Waiting 5s..." >&2
sleep 5
fi
fi
# Proactive monitoring
remaining=$(grep -i "X-RateLimit-Remaining:" /tmp/fh_headers.txt \
| awk '{print $2}' | tr -d '\r')
limit=$(grep -i "X-RateLimit-Limit:" /tmp/fh_headers.txt \
| awk '{print $2}' | tr -d '\r')
if [ -n "$remaining" ] && [ -n "$limit" ] && [ "$limit" -gt 0 ]; then
percent_used=$(( (limit - remaining) * 100 / limit ))
echo "Rate limit usage: ${percent_used}% ($remaining/$limit remaining)"
if [ $percent_used -ge 90 ]; then
echo "WARNING: Approaching rate limit. Slow down requests." >&2
fi
fiIdempotency Keys
To safely retry requests without risking duplicate charges or duplicate generations, include the X-Idempotency-Key header. If a request with the same key is received within 24 hours, the API returns the original cached response without re-executing the operation.
Using an SDK? This is already handled
The Python and TypeScript SDKs retry automatically (three attempts by default), which is exactly the situation this header exists for. From version 1.10.0 they mint one key per logical call and reuse it across that call's retries, so a timeout arriving after a render already started is replayed rather than charged again. Nothing to pass — but do upgrade if you are below 1.10.0.
Two separate calls always get two different keys, even with identical arguments: asking twice means you want two generations.
Rules
Important
- Keys must be unique per distinct operation (use UUIDs).
- Keys expire after 24 hours.
- Same key + different request body =
422. The key is not reused for a new request, and the earlier response is not returned in its place — silently answering a new prompt with an old image would be indistinguishable from a bug. - Keys are scoped to your API key — different API keys can use the same idempotency key independently.
- Only applicable to mutating operations (POST, PUT, PATCH). GET requests are naturally idempotent.
- Applies to the generation and media endpoints (
/v1/ai/*,/v1/images/*,/v1/video/*,/v1/shorts/*,/v1/story/*,/v1/3d/*,/v1/voice/*) — the calls that spend money. Streaming endpoints are excluded, because a buffered stream could not be replayed and would have to be held back in full before the first byte reached you:/v1/ai/chat/*,/v1/ai/agent/stream,/v1/ai/gabriel/*,/v1/ai/tts/*and/v1/story/generate.
Responses
| Situation | Status | What it means |
|---|---|---|
| First request with this key | normal response | The operation ran and was charged once. |
| Repeat, first one finished | original response + Idempotent-Replay: true | Replayed from cache. Nothing was charged again. |
| Repeat, first one still running | 409 + Retry-After | Your original call is in flight. Retry shortly to collect its result; the operation is charged once. |
| Same key, different body | 422 | Use a new key for a new request. |
| First attempt failed (4xx/5xx) | the error | Nothing is cached, so the same key can be retried freely. |
The Idempotent-Replay: true response header is how you tell a replay from a fresh, separately charged execution without comparing bodies.
Implementation
import uuid
import time
import random
import requests
API_BASE = "https://apis.fotohub.app/v1"
API_KEY = "fh_live_your_api_key"
def generate_with_idempotency(prompt: str, max_retries: int = 3) -> dict:
"""
Generate an image with idempotency protection.
Safe to retry on failure — the server guarantees at-most-once execution.
"""
# Generate a unique key for this logical operation
idempotency_key = str(uuid.uuid4())
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"X-Idempotency-Key": idempotency_key,
}
payload = {
"model": "seedream-5-0-260128",
"prompt": prompt,
"width": 1024,
"height": 1024,
}
for attempt in range(max_retries):
try:
response = requests.post(
f"{API_BASE}/ai/generate/image",
headers=headers,
json=payload,
timeout=60,
)
if response.status_code < 400:
return response.json()
# Retryable errors — safe to retry with same idempotency key.
# 409 belongs here ONLY because we are sending an idempotency key:
# it means our own earlier attempt is still in flight, so waiting
# collects its result. Without a key, 409 is a real conflict.
if response.status_code in (409, 429, 500, 502, 503, 504):
if attempt < max_retries - 1:
delay = min(1 * (2 ** attempt), 30) + random.uniform(0, 1)
retry_after = response.headers.get("retry-after")
if retry_after:
delay = max(delay, float(retry_after))
print(f"Retrying ({attempt + 1}/{max_retries}) in {delay:.1f}s...")
time.sleep(delay)
continue
# Non-retryable error
error_body = response.json()
raise Exception(
f"API error [{error_body['error']}]: {error_body['message']} "
f"(request_id: {error_body['request_id']})"
)
except requests.exceptions.Timeout:
# Timeout — safe to retry with same idempotency key
if attempt < max_retries - 1:
delay = min(2 * (2 ** attempt), 30)
time.sleep(delay)
continue
raise
raise Exception(f"Failed after {max_retries} retries")
# Usage — even if the first request times out, the retry returns the same
# cached result (charged only once)
result = generate_with_idempotency("A sunset over the ocean")
print(f"URL: {result['url']}")import { randomUUID } from "crypto";
const API_BASE = "https://apis.fotohub.app/v1";
const API_KEY = "fh_live_your_api_key";
async function generateWithIdempotency(
prompt: string,
maxRetries = 3
): Promise<{ url: string }> {
/**
* Generate an image with idempotency protection.
* Safe to retry on failure — at-most-once execution guaranteed.
*/
const idempotencyKey = randomUUID();
const headers = {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"X-Idempotency-Key": idempotencyKey,
};
const body = JSON.stringify({
model: "seedream-5-0-260128",
prompt,
width: 1024,
height: 1024,
});
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(`${API_BASE}/ai/generate/image`, {
method: "POST",
headers,
body,
signal: AbortSignal.timeout(60000),
});
if (response.ok) {
return response.json();
}
// Retryable errors — safe to retry with same idempotency key.
// 409 belongs here ONLY because we send an idempotency key: it means our
// own earlier attempt is still in flight, so waiting collects its result.
// Without a key, 409 is a real conflict and must not be retried.
if ([409, 429, 500, 502, 503, 504].includes(response.status)) {
if (attempt < maxRetries - 1) {
const retryAfter = Number(response.headers.get("retry-after")) * 1000;
const delay = Math.max(
Math.min(1000 * Math.pow(2, attempt), 30000),
Number.isFinite(retryAfter) ? retryAfter : 0
);
const jitter = Math.random() * 1000;
console.warn(`Retrying (${attempt + 1}/${maxRetries}) in ${((delay + jitter) / 1000).toFixed(1)}s...`);
await new Promise((r) => setTimeout(r, delay + jitter));
continue;
}
}
// Non-retryable error
const errorBody = await response.json();
throw new Error(
`API error [${errorBody.error}]: ${errorBody.message} ` +
`(request_id: ${errorBody.request_id})`
);
} catch (e: any) {
if (e.name === "TimeoutError" && attempt < maxRetries - 1) {
const delay = Math.min(2000 * Math.pow(2, attempt), 30000);
await new Promise((r) => setTimeout(r, delay));
continue;
}
if (attempt === maxRetries - 1) throw e;
}
}
throw new Error(`Failed after ${maxRetries} retries`);
}
// Usage — even if the first request times out, the retry returns the
// same cached result (charged only once)
const result = await generateWithIdempotency("A sunset over the ocean");
console.log(`URL: ${result.url}`);package main
import (
"bytes"
"encoding/json"
"fmt"
"math"
"math/rand"
"net/http"
"time"
"github.com/google/uuid"
)
const (
apiBase = "https://apis.fotohub.app/v1"
apiKey = "fh_live_your_api_key"
)
func generateWithIdempotency(prompt string, maxRetries int) (map[string]interface{}, error) {
// Generate a unique key for this logical operation
idempotencyKey := uuid.New().String()
payload, _ := json.Marshal(map[string]interface{}{
"model": "seedream-5-0-260128",
"prompt": prompt,
"width": 1024,
"height": 1024,
})
// 409 is retryable here ONLY because we send an idempotency key: it means
// our own earlier attempt is still in flight. Without a key it is a real
// conflict and must not be retried.
retryableStatusCodes := map[int]bool{
409: true, 429: true, 500: true, 502: true, 503: true, 504: true,
}
for attempt := 0; attempt < maxRetries; attempt++ {
req, _ := http.NewRequest("POST",
apiBase+"/ai/generate/image",
bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Idempotency-Key", idempotencyKey)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
// Network/timeout error — safe to retry with same key
if attempt < maxRetries-1 {
delay := time.Duration(math.Min(
float64(2*time.Second)*math.Pow(2, float64(attempt)),
float64(30*time.Second),
))
time.Sleep(delay)
continue
}
return nil, err
}
defer resp.Body.Close()
// Success
if resp.StatusCode < 400 {
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}
// Retryable errors — safe to retry with same idempotency key
if retryableStatusCodes[resp.StatusCode] && attempt < maxRetries-1 {
delay := math.Min(
float64(time.Second)*math.Pow(2, float64(attempt)),
float64(30*time.Second),
)
jitter := rand.Float64() * float64(time.Second)
fmt.Printf("Retrying (%d/%d) in %v...\n", attempt+1, maxRetries,
time.Duration(delay+jitter))
time.Sleep(time.Duration(delay + jitter))
continue
}
// Non-retryable error
var errBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&errBody)
return nil, fmt.Errorf("API error [%v]: %v (request_id: %v)",
errBody["error"], errBody["message"], errBody["request_id"])
}
return nil, fmt.Errorf("failed after %d retries", maxRetries)
}
func main() {
result, err := generateWithIdempotency("A sunset over the ocean", 3)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("URL: %s\n", result["url"])
}#!/bin/bash
# Idempotent request with retry for FOTOhub API.
# Uses X-Idempotency-Key header to prevent duplicate executions.
API_BASE="https://apis.fotohub.app/v1"
API_KEY="fh_live_your_api_key"
# Generate a unique idempotency key (UUID v4)
IDEMPOTENCY_KEY=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)
MAX_RETRIES=3
attempt=0
echo "Using idempotency key: $IDEMPOTENCY_KEY"
while [ $attempt -lt $MAX_RETRIES ]; do
response=$(curl -s -w "\n%{http_code}" \
-X POST "$API_BASE/ai/generate/image" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: $IDEMPOTENCY_KEY" \
--max-time 60 \
-d '{
"model": "seedream-5-0-260128",
"prompt": "A sunset over the ocean",
"width": 1024,
"height": 1024
}')
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
# Success
if [ "$http_code" -lt 400 ] 2>/dev/null; then
echo "Generated: $(echo "$body" | jq -r '.url')"
exit 0
fi
# Retryable errors (429, 500-504) — safe to retry with same key
if [ "$http_code" = "429" ] || [ "$http_code" -ge 500 ] 2>/dev/null; then
attempt=$((attempt + 1))
if [ $attempt -lt $MAX_RETRIES ]; then
delay=$((1 * (2 ** (attempt - 1))))
echo "Retrying ($attempt/$MAX_RETRIES) in ${delay}s..." >&2
sleep $delay
continue
fi
fi
# Non-retryable error or max retries reached
echo "Error HTTP $http_code: $body" >&2
exit 1
done
echo "Failed after $MAX_RETRIES retries" >&2
exit 1Circuit Breaker Pattern
After multiple consecutive failures, implement a circuit breaker to pause requests and avoid overwhelming a recovering service. This protects both your application and the API from cascading failures.
Circuit Breaker States
| State | Behavior |
|---|---|
| Closed | Requests flow normally. Failures are counted. |
| Open | All requests are immediately rejected locally (no API call made). |
| Half-Open | A single probe request is allowed through. If it succeeds, circuit closes. If it fails, circuit re-opens. |
State Transitions
[Closed] ---(failure_count >= threshold)---> [Open]
[Open] ---(reset_timeout elapsed)---> [Half-Open]
[Half-Open] ---(probe succeeds)---> [Closed]
[Half-Open] ---(probe fails)---> [Open]Configuration Recommendations
| Parameter | Default | Description |
|---|---|---|
failure_threshold | 5 | Consecutive failures before opening the circuit |
reset_timeout | 30s | Time to wait before allowing a probe request |
half_open_max_calls | 1 | Number of probe requests allowed in half-open state |
counted_statuses | 429, 500-504 | HTTP statuses counted as failures |
Implementation
import time
from enum import Enum
from typing import Callable, TypeVar
T = TypeVar("T")
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitOpenError(Exception):
"""Raised when the circuit breaker is open and rejecting requests."""
pass
class CircuitBreaker:
"""
Circuit breaker for FOTOhub API calls.
Opens after consecutive failures, waits, then probes with a single request.
"""
def __init__(
self,
failure_threshold: int = 5,
reset_timeout: float = 30.0,
half_open_max_calls: int = 1,
):
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
self.half_open_max_calls = half_open_max_calls
self.failure_count = 0
self.last_failure_time = 0.0
self.state = CircuitState.CLOSED
self.half_open_calls = 0
def execute(self, fn: Callable[..., T], *args, **kwargs) -> T:
"""Execute a function through the circuit breaker."""
self._check_state_transition()
if self.state == CircuitState.OPEN:
remaining = self.reset_timeout - (time.time() - self.last_failure_time)
raise CircuitOpenError(
f"Circuit breaker is open. Retry after {remaining:.0f}s"
)
if self.state == CircuitState.HALF_OPEN:
if self.half_open_calls >= self.half_open_max_calls:
raise CircuitOpenError("Circuit breaker half-open: probe in progress")
self.half_open_calls += 1
try:
result = fn(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure(e)
raise
def _check_state_transition(self):
if self.state == CircuitState.OPEN:
elapsed = time.time() - self.last_failure_time
if elapsed >= self.reset_timeout:
self.state = CircuitState.HALF_OPEN
self.half_open_calls = 0
def _on_success(self):
self.failure_count = 0
self.state = CircuitState.CLOSED
self.half_open_calls = 0
def _on_failure(self, error: Exception):
# Only count retryable errors
status = getattr(error, "status", 0)
if status >= 500 or status == 429:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
print(
f"Circuit breaker OPEN after {self.failure_count} failures. "
f"Will probe in {self.reset_timeout}s."
)
elif self.state == CircuitState.HALF_OPEN:
self.state = CircuitState.OPEN
print("Circuit breaker re-opened after failed probe.")
# Usage with the retry function
breaker = CircuitBreaker(failure_threshold=5, reset_timeout=30.0)
def generate_image(prompt: str) -> dict:
return breaker.execute(
request_with_retry,
"POST",
"/ai/generate/image",
{"model": "seedream-5-0-260128", "prompt": prompt},
)
# Handle circuit breaker errors gracefully
try:
result = generate_image("A mountain landscape")
print(f"URL: {result['url']}")
except CircuitOpenError as e:
print(f"Service unavailable: {e}")
# Fall back to cached result, queue for later, or show maintenance page
except FotohubAPIError as e:
print(f"API error: {e}")type CircuitState = "closed" | "open" | "half-open";
class CircuitOpenError extends Error {
constructor(message: string) {
super(message);
this.name = "CircuitOpenError";
}
}
class CircuitBreaker {
private failureCount = 0;
private lastFailureTime = 0;
private state: CircuitState = "closed";
private halfOpenCalls = 0;
constructor(
private readonly failureThreshold: number = 5,
private readonly resetTimeoutMs: number = 30000,
private readonly halfOpenMaxCalls: number = 1
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
this.checkStateTransition();
if (this.state === "open") {
const remaining = Math.ceil(
(this.resetTimeoutMs - (Date.now() - this.lastFailureTime)) / 1000
);
throw new CircuitOpenError(
`Circuit breaker is open. Retry after ${remaining}s`
);
}
if (this.state === "half-open") {
if (this.halfOpenCalls >= this.halfOpenMaxCalls) {
throw new CircuitOpenError("Circuit breaker half-open: probe in progress");
}
this.halfOpenCalls++;
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error: any) {
this.onFailure(error);
throw error;
}
}
private checkStateTransition(): void {
if (this.state === "open") {
if (Date.now() - this.lastFailureTime >= this.resetTimeoutMs) {
this.state = "half-open";
this.halfOpenCalls = 0;
}
}
}
private onSuccess(): void {
this.failureCount = 0;
this.state = "closed";
this.halfOpenCalls = 0;
}
private onFailure(error: any): void {
const status = error?.status ?? error?.statusCode ?? 0;
if (status >= 500 || status === 429) {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = "open";
console.warn(
`Circuit breaker OPEN after ${this.failureCount} failures. ` +
`Will probe in ${this.resetTimeoutMs / 1000}s.`
);
} else if (this.state === "half-open") {
this.state = "open";
console.warn("Circuit breaker re-opened after failed probe.");
}
}
}
}
// Usage with the retry function
const breaker = new CircuitBreaker(5, 30000);
async function generateImage(prompt: string) {
return breaker.execute(() =>
requestWithRetry<{ url: string }>("POST", "/ai/generate/image", {
model: "seedream-5-0-260128",
prompt,
})
);
}
// Handle circuit breaker errors gracefully
try {
const result = await generateImage("A mountain landscape");
console.log(`URL: ${result.url}`);
} catch (e) {
if (e instanceof CircuitOpenError) {
console.log(`Service unavailable: ${e.message}`);
// Fall back to cached result or show maintenance page
} else if (e instanceof FotohubAPIError) {
console.error(`API error [${e.code}]: ${e.message}`);
}
}package main
import (
"errors"
"fmt"
"sync"
"time"
)
type CircuitState int
const (
StateClosed CircuitState = iota
StateOpen
StateHalfOpen
)
var ErrCircuitOpen = errors.New("circuit breaker is open")
type CircuitBreaker struct {
mu sync.Mutex
state CircuitState
failureCount int
lastFailureTime time.Time
failureThreshold int
resetTimeout time.Duration
halfOpenCalls int
halfOpenMax int
}
func NewCircuitBreaker(threshold int, timeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
failureThreshold: threshold,
resetTimeout: timeout,
state: StateClosed,
halfOpenMax: 1,
}
}
func (cb *CircuitBreaker) Execute(fn func() (map[string]interface{}, error)) (map[string]interface{}, error) {
cb.mu.Lock()
// Check state transition
if cb.state == StateOpen {
if time.Since(cb.lastFailureTime) >= cb.resetTimeout {
cb.state = StateHalfOpen
cb.halfOpenCalls = 0
} else {
remaining := cb.resetTimeout - time.Since(cb.lastFailureTime)
cb.mu.Unlock()
return nil, fmt.Errorf("%w: retry after %v", ErrCircuitOpen, remaining.Round(time.Second))
}
}
if cb.state == StateHalfOpen && cb.halfOpenCalls >= cb.halfOpenMax {
cb.mu.Unlock()
return nil, fmt.Errorf("%w: probe in progress", ErrCircuitOpen)
}
if cb.state == StateHalfOpen {
cb.halfOpenCalls++
}
cb.mu.Unlock()
// Execute the function
result, err := fn()
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil {
cb.failureCount++
cb.lastFailureTime = time.Now()
if cb.failureCount >= cb.failureThreshold {
cb.state = StateOpen
fmt.Printf("Circuit breaker OPEN after %d failures. Will probe in %v.\n",
cb.failureCount, cb.resetTimeout)
} else if cb.state == StateHalfOpen {
cb.state = StateOpen
fmt.Println("Circuit breaker re-opened after failed probe.")
}
return nil, err
}
// Success — reset
cb.failureCount = 0
cb.state = StateClosed
cb.halfOpenCalls = 0
return result, nil
}
func main() {
breaker := NewCircuitBreaker(5, 30*time.Second)
result, err := breaker.Execute(func() (map[string]interface{}, error) {
return requestWithRetry("POST", "/ai/generate/image", map[string]interface{}{
"model": "seedream-5-0-260128",
"prompt": "A mountain landscape",
})
})
if err != nil {
if errors.Is(err, ErrCircuitOpen) {
fmt.Printf("Service unavailable: %v\n", err)
// Fall back to cached result or queue for later
} else {
fmt.Printf("API error: %v\n", err)
}
return
}
fmt.Printf("URL: %s\n", result["url"])
}#!/bin/bash
# Simple circuit breaker for shell scripts.
# Tracks consecutive failures in a temp file and pauses requests
# when the threshold is exceeded.
CIRCUIT_FILE="/tmp/fotohub_circuit.json"
FAILURE_THRESHOLD=5
RESET_TIMEOUT=30 # seconds
# Initialize circuit state file
init_circuit() {
if [ ! -f "$CIRCUIT_FILE" ]; then
echo '{"failures": 0, "last_failure": 0}' > "$CIRCUIT_FILE"
fi
}
# Check circuit state: returns "closed", "open", or "half-open"
check_circuit() {
init_circuit
local failures last_failure now elapsed
failures=$(jq -r '.failures' "$CIRCUIT_FILE" 2>/dev/null || echo 0)
last_failure=$(jq -r '.last_failure' "$CIRCUIT_FILE" 2>/dev/null || echo 0)
now=$(date +%s)
elapsed=$((now - last_failure))
if [ "$failures" -ge "$FAILURE_THRESHOLD" ]; then
if [ "$elapsed" -lt "$RESET_TIMEOUT" ]; then
echo "open"
else
echo "half-open"
fi
else
echo "closed"
fi
}
# Record a failure
record_failure() {
init_circuit
local failures
failures=$(jq -r '.failures' "$CIRCUIT_FILE" 2>/dev/null || echo 0)
failures=$((failures + 1))
echo "{\"failures\": $failures, \"last_failure\": $(date +%s)}" > "$CIRCUIT_FILE"
if [ "$failures" -ge "$FAILURE_THRESHOLD" ]; then
echo "Circuit breaker OPEN after $failures failures. Will probe in ${RESET_TIMEOUT}s." >&2
fi
}
# Record a success (reset the circuit)
record_success() {
echo '{"failures": 0, "last_failure": 0}' > "$CIRCUIT_FILE"
}
# Make a request through the circuit breaker
fotohub_with_circuit() {
local state
state=$(check_circuit)
if [ "$state" = "open" ]; then
local failures last_failure remaining
last_failure=$(jq -r '.last_failure' "$CIRCUIT_FILE")
remaining=$((RESET_TIMEOUT - ($(date +%s) - last_failure)))
echo "Circuit breaker is open. Retry after ${remaining}s." >&2
return 1
fi
# Make the actual request (uses fotohub_request from retry example)
if fotohub_request "$@"; then
record_success
return 0
else
record_failure
return 1
fi
}
# Usage
fotohub_with_circuit "POST" "/ai/generate/image" '{
"model": "seedream-5-0-260128",
"prompt": "A mountain landscape"
}'Request ID for Support
Every response carries an X-Request-Id header holding a UUID that identifies that one call in our request log:
X-Request-Id: 1878ab43-df35-460d-9336-9cc80d60c559It is on every response, including the two that never reach your handler: a 429 from the rate limiter and a 500. Read it from the headers rather than the body — the body carries request_id only on a 500, where the message is deliberately generic and there is nothing else to quote:
{
"detail": "Internal server error",
"request_id": "1878ab43-df35-460d-9336-9cc80d60c559"
}Every other error returns just detail:
{ "detail": "prompt is required" }A few properties worth relying on:
- We mint it, always. An
X-Request-Idyou send on the way in is ignored, so the value coming back is never the one you supplied. Use your own header (or an idempotency key) if you need to correlate with your own logs. - One id per HTTP call, not per job. For a long generation, the id identifies the submit or the poll you made — the job itself is tracked by its
job_id. - It is readable from browser JavaScript.
X-Request-Idis listed inAccess-Control-Expose-Headers, along withX-RateLimit-*,X-TierandRetry-After.
You can look an id up yourself in the developer console under Logs — paste it into the request-id box. That search ignores the selected time range, so an id from an old ticket still resolves. Requests made before this header existed have no id recorded, and show an em dash rather than a fabricated value.
When contacting support, include:
- The
X-Request-Id - Timestamp of the request
- The endpoint and parameters used
Logging Request IDs
import logging
import requests
logger = logging.getLogger("fotohub")
def log_fotohub_request(response: requests.Response, endpoint: str) -> None:
"""Log every FOTOhub API response for traceability."""
request_id = response.headers.get("X-Request-Id", "unknown")
if response.status_code < 400:
logger.info(
f"FOTOhub OK: {endpoint} -> {response.status_code} "
f"(request_id: {request_id})"
)
else:
try:
error_body = response.json()
logger.error(
f"FOTOhub ERROR: [{error_body['error']}] {error_body['message']} "
f"endpoint={endpoint} status={response.status_code} "
f"request_id={error_body.get('request_id', request_id)}"
)
except ValueError:
logger.error(
f"FOTOhub ERROR: {endpoint} -> {response.status_code} "
f"(request_id: {request_id}, body not JSON)"
)
# Usage
response = requests.post(
"https://apis.fotohub.app/v1/ai/generate/image",
headers={"Authorization": "Bearer fh_live_your_api_key"},
json={"model": "seedream-5-0-260128", "prompt": "A landscape"},
)
log_fotohub_request(response, "/ai/generate/image")function logFotohubRequest(
response: Response,
endpoint: string,
body?: Record<string, unknown>
): void {
const requestId = response.headers.get("X-Request-Id") ?? "unknown";
if (response.ok) {
console.log(
`FOTOhub OK: ${endpoint} -> ${response.status} (request_id: ${requestId})`
);
} else {
// Log the error details for debugging
response
.clone()
.json()
.then((errorBody) => {
console.error(
`FOTOhub ERROR: [${errorBody.error}] ${errorBody.message} ` +
`endpoint=${endpoint} status=${response.status} ` +
`request_id=${errorBody.request_id ?? requestId}`
);
})
.catch(() => {
console.error(
`FOTOhub ERROR: ${endpoint} -> ${response.status} ` +
`(request_id: ${requestId}, body not JSON)`
);
});
}
}
// Usage
const response = await fetch("https://apis.fotohub.app/v1/ai/generate/image", {
method: "POST",
headers: {
Authorization: "Bearer fh_live_your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "seedream-5-0-260128", prompt: "A landscape" }),
});
logFotohubRequest(response, "/ai/generate/image");package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
func logFotohubRequest(resp *http.Response, endpoint string) {
requestID := resp.Header.Get("X-Request-Id")
if requestID == "" {
requestID = "unknown"
}
if resp.StatusCode < 400 {
log.Printf("FOTOhub OK: %s -> %d (request_id: %s)",
endpoint, resp.StatusCode, requestID)
} else {
var errBody map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errBody); err == nil {
log.Printf("FOTOhub ERROR: [%v] %v endpoint=%s status=%d request_id=%v",
errBody["error"], errBody["message"],
endpoint, resp.StatusCode,
errBody["request_id"])
} else {
log.Printf("FOTOhub ERROR: %s -> %d (request_id: %s, body not JSON)",
endpoint, resp.StatusCode, requestID)
}
}
}
// Usage in your request flow
func example() {
req, _ := http.NewRequest("POST",
"https://apis.fotohub.app/v1/ai/generate/image", nil)
req.Header.Set("Authorization", "Bearer fh_live_your_api_key")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("Network error: %v", err)
return
}
defer resp.Body.Close()
logFotohubRequest(resp, "/ai/generate/image")
// Always include request_id in alerts to your monitoring system
requestID := resp.Header.Get("X-Request-Id")
if resp.StatusCode >= 500 {
fmt.Printf("ALERT: Server error on /ai/generate/image — request_id: %s\n", requestID)
}
}#!/bin/bash
# Log request IDs from every FOTOhub API call
API_BASE="https://apis.fotohub.app/v1"
API_KEY="fh_live_your_api_key"
# Make request and capture both headers and body
response=$(curl -s -w "\n%{http_code}" \
-D /tmp/fh_headers.txt \
-X POST "$API_BASE/ai/generate/image" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "seedream-5-0-260128", "prompt": "A landscape"}')
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
# Extract request_id from headers
request_id=$(grep -i "X-Request-Id:" /tmp/fh_headers.txt \
| awk '{print $2}' | tr -d '\r')
# Log appropriately
if [ "$http_code" -lt 400 ] 2>/dev/null; then
echo "[OK] /ai/generate/image -> HTTP $http_code (request_id: $request_id)"
else
error_code=$(echo "$body" | jq -r '.error // "unknown"')
message=$(echo "$body" | jq -r '.message // "unknown"')
echo "[ERROR] [$error_code] $message (HTTP $http_code, request_id: $request_id)" >&2
# For server errors, save full context for support ticket
if [ "$http_code" -ge 500 ] 2>/dev/null; then
echo "Support ticket info:" >&2
echo " request_id: $request_id" >&2
echo " timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2
echo " endpoint: POST /ai/generate/image" >&2
echo " status: $http_code" >&2
fi
fiError Recovery by Category
Complete error handling patterns for each category of error. Use the error field for programmatic routing and implement the appropriate recovery strategy.
Authentication Errors (401)
from fotohub import FotoHub
def handle_auth_error(error_code: str, request_id: str):
"""Handle authentication failures with appropriate recovery."""
if error_code == "invalid_api_key":
# Key format wrong — check environment variable
print("Invalid API key format. Keys must start with fh_live_ or fh_test_.")
print("Check your FOTOHUB_API_KEY environment variable.")
elif error_code == "expired_api_key":
# Key expired — generate a new one
print("API key has expired. Generate a new key at fotohub.app/console/keys")
elif error_code == "revoked_api_key":
# Key was manually revoked — cannot be restored
print("API key was revoked. Create a new key — revoked keys cannot be restored.")
else:
print(f"Authentication failed [{error_code}] (request_id: {request_id})")
# Usage pattern with SDK
try:
client = FotoHub(api_key="fh_live_your_api_key")
result = client.images.generate(
model="seedream-5-0-260128",
prompt="A landscape",
)
except Exception as e:
if hasattr(e, "status") and e.status == 401:
handle_auth_error(e.error, e.request_id)function handleAuthError(errorCode: string, requestId: string): void {
switch (errorCode) {
case "invalid_api_key":
console.error(
"Invalid API key format. Keys must start with fh_live_ or fh_test_."
);
console.error("Check your FOTOHUB_API_KEY environment variable.");
break;
case "expired_api_key":
console.error(
"API key has expired. Generate a new key at fotohub.app/console/keys"
);
break;
case "revoked_api_key":
console.error(
"API key was revoked. Create a new key - revoked keys cannot be restored."
);
break;
default:
console.error(`Authentication failed [${errorCode}] (request_id: ${requestId})`);
}
}
// Usage pattern
try {
const result = await requestWithRetry("POST", "/ai/generate/image", {
model: "seedream-5-0-260128",
prompt: "A landscape",
});
} catch (e) {
if (e instanceof FotohubAPIError && e.status === 401) {
handleAuthError(e.code, e.requestId);
}
}package main
import "fmt"
func handleAuthError(errorCode, requestID string) {
switch errorCode {
case "invalid_api_key":
fmt.Println("Invalid API key format. Keys must start with fh_live_ or fh_test_.")
fmt.Println("Check your FOTOHUB_API_KEY environment variable.")
case "expired_api_key":
fmt.Println("API key has expired. Generate a new key at fotohub.app/console/keys")
case "revoked_api_key":
fmt.Println("API key was revoked. Create a new key — revoked keys cannot be restored.")
default:
fmt.Printf("Authentication failed [%s] (request_id: %s)\n", errorCode, requestID)
}
}#!/bin/bash
# Handle 401 authentication errors
response=$(curl -s -w "\n%{http_code}" \
-X POST "https://apis.fotohub.app/v1/ai/generate/image" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "seedream-5-0-260128", "prompt": "A landscape"}')
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" = "401" ]; then
error_code=$(echo "$body" | jq -r '.error')
case "$error_code" in
invalid_api_key)
echo "Invalid API key. Check FOTOHUB_API_KEY env var." >&2
echo "Keys must start with fh_live_ or fh_test_." >&2
;;
expired_api_key)
echo "API key expired. Generate a new key at fotohub.app/console/keys" >&2
;;
revoked_api_key)
echo "API key revoked. Create a new key." >&2
;;
esac
exit 1
fiBilling Errors (402)
There is one billing error to handle: the wallet cannot pay. The amounts live in detail, and every one of them is optional — the storage endpoints send a balance without a price, so read them defensively and fall back to the message, which is always complete enough to show a user.
TOPUP_URL = "https://fotohub.app/console/wallet"
def handle_insufficient_funds(detail: dict) -> dict:
"""Turn a 402 body into something to show, and something to do about it."""
balance = detail.get("balance_usd")
required = detail.get("required_usd")
shortfall = detail.get("shortfall_usd")
# Accrual-billed endpoints (storage) quote no price: there is no single
# amount, only "the wallet is empty". Do not print `$None`.
if required is None:
print(f"Wallet empty (${balance:.2f}). {detail.get('message', '')}")
else:
print(f"This request costs ${required:.6f}, wallet holds ${balance:.6f}")
print(f"Top up at least ${shortfall:.6f}")
# Nothing was charged and no provider was called, so the operation can simply
# be retried once the wallet is funded — no idempotency key needed.
return {
"action": "top_up",
"url": detail.get("topup_url") or TOPUP_URL,
"min_topup_usd": shortfall,
"retryable_after_topup": True,
}
# Usage — graceful degradation in a web app
response = requests.post(
f"{API_BASE}/ai/generate/image",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"model": "seedream-5-0-260128", "prompt": "A landscape"},
timeout=60,
)
if response.status_code == 402:
detail = response.json().get("detail")
if isinstance(detail, dict) and detail.get("error") == "insufficient_funds":
recovery = handle_insufficient_funds(detail)
# return redirect(recovery["url"])
else:
# The other 402 is a plan cap on a resource — `plan_gate_exceeded`.
# Topping up does not clear it; the account needs a bigger plan.
print(detail)const TOPUP_URL = "https://fotohub.app/console/wallet";
interface InsufficientFunds {
error: "insufficient_funds";
message: string;
balance_usd: number;
// Absent on the storage endpoints, which bill by hourly accrual and so have
// no per-request price to quote.
required_usd?: number;
shortfall_usd?: number;
topup_url?: string;
operation?: string;
charged: false;
}
function isInsufficientFunds(detail: unknown): detail is InsufficientFunds {
return (
typeof detail === "object" &&
detail !== null &&
(detail as { error?: string }).error === "insufficient_funds"
);
}
// Usage in a Next.js API route or similar
const res = await fetch(`${API_BASE}/ai/generate/image`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "seedream-5-0-260128", prompt: "A landscape" }),
});
if (res.status === 402) {
const { detail } = await res.json();
if (isInsufficientFunds(detail)) {
// Show the amounts if the endpoint quoted them, the message otherwise.
console.error(detail.message);
// return NextResponse.redirect(detail.topup_url ?? TOPUP_URL);
} else {
// `plan_gate_exceeded` — a resource cap, not an empty wallet. Topping up
// does not clear it.
console.error(detail);
}
}package main
import (
"encoding/json"
"fmt"
)
const topupURL = "https://fotohub.app/console/wallet"
// Pointers, not float64: `required_usd` is absent on the storage endpoints, and
// a plain float64 would decode that absence as 0 — an amount, and the wrong one.
type insufficientFunds struct {
Error string `json:"error"`
Message string `json:"message"`
BalanceUsd *float64 `json:"balance_usd"`
RequiredUsd *float64 `json:"required_usd"`
ShortfallUsd *float64 `json:"shortfall_usd"`
TopupURL string `json:"topup_url"`
Operation string `json:"operation"`
Charged bool `json:"charged"`
}
// handleBillingError reports a 402 and returns the minimum top-up that clears
// it, or nil when the endpoint quoted no amount.
func handleBillingError(body []byte) *float64 {
var envelope struct {
Detail insufficientFunds `json:"detail"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil
}
d := envelope.Detail
if d.Error != "insufficient_funds" {
// plan_gate_exceeded — a resource cap. Money will not fix it.
fmt.Println(d.Message)
return nil
}
url := d.TopupURL
if url == "" {
url = topupURL
}
if d.RequiredUsd == nil {
fmt.Printf("Wallet empty. %s\nTop up at: %s\n", d.Message, url)
return nil
}
fmt.Printf("Costs $%.6f, wallet holds $%.6f. Top up $%.6f at %s\n",
*d.RequiredUsd, *d.BalanceUsd, *d.ShortfallUsd, url)
return d.ShortfallUsd
}#!/bin/bash
# Handle 402 billing errors
response=$(curl -s -w "\n%{http_code}" \
-X POST "https://apis.fotohub.app/v1/ai/generate/image" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "seedream-5-0-260128", "prompt": "A landscape"}')
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" = "402" ]; then
error_code=$(echo "$body" | jq -r '.detail.error // ""')
topup_url=$(echo "$body" | jq -r '.detail.topup_url // "https://fotohub.app/console/wallet"')
if [ "$error_code" = "insufficient_funds" ]; then
# `// empty` leaves these unset on the storage endpoints, which quote a
# balance but no price. `// 0` would invent an amount.
required=$(echo "$body" | jq -r '.detail.required_usd // empty')
shortfall=$(echo "$body" | jq -r '.detail.shortfall_usd // empty')
balance=$(echo "$body" | jq -r '.detail.balance_usd')
if [ -n "$required" ]; then
echo "Costs \$$required, wallet holds \$$balance. Top up \$$shortfall" >&2
else
echo "Wallet empty (\$$balance)." >&2
fi
echo "Top up at: $topup_url" >&2
else
# plan_gate_exceeded, or a plain string detail.
echo "$body" | jq -r '.detail.message // .detail' >&2
fi
exit 1
fiModel and Generation Errors (500/502/503)
import time
# Fallback model configuration
FALLBACK_MODELS = {
"seedream-5-0-260128": "flux-2-klein-4b",
"kling-v3": "veo-3.1-generate-001",
"music-minimax": None, # No fallback
}
def generate_with_fallback(
model: str,
prompt: str,
max_retries: int = 2,
**kwargs,
) -> dict:
"""
Generate with automatic model fallback on provider errors.
Tries the primary model first, then falls back to an alternative.
"""
models_to_try = [model]
fallback = FALLBACK_MODELS.get(model)
if fallback:
models_to_try.append(fallback)
last_error = None
for current_model in models_to_try:
try:
result = request_with_retry(
"POST",
"/ai/generate/image",
{"model": current_model, "prompt": prompt, **kwargs},
max_retries=max_retries,
)
if current_model != model:
print(f"Used fallback model: {current_model} (primary: {model})")
return result
except FotohubAPIError as e:
last_error = e
if e.error in ("model_unavailable", "model_overloaded", "provider_error"):
print(f"Model {current_model} unavailable [{e.error}], trying fallback...")
continue
elif e.error == "timeout":
print(f"Model {current_model} timed out, trying fallback...")
continue
else:
# Non-model error (auth, billing, validation) — do not fallback
raise
# All models failed
raise last_error
# Usage
result = generate_with_fallback(
model="seedream-5-0-260128",
prompt="A mountain at sunset",
width=1024,
height=1024,
)
print(f"URL: {result['url']}")// Fallback model configuration
const FALLBACK_MODELS: Record<string, string | null> = {
"seedream-5-0-260128": "flux-2-klein-4b",
"kling-v3": "veo-3.1-generate-001",
"music-minimax": null, // No fallback
};
async function generateWithFallback(
model: string,
prompt: string,
options: Record<string, unknown> = {}
): Promise<{ url: string }> {
/**
* Generate with automatic model fallback on provider errors.
* Tries primary model first, then alternative.
*/
const modelsToTry = [model];
const fallback = FALLBACK_MODELS[model];
if (fallback) modelsToTry.push(fallback);
let lastError: Error | null = null;
for (const currentModel of modelsToTry) {
try {
const result = await requestWithRetry<{ url: string }>(
"POST",
"/ai/generate/image",
{ model: currentModel, prompt, ...options },
{ maxRetries: 2 }
);
if (currentModel !== model) {
console.warn(`Used fallback model: ${currentModel} (primary: ${model})`);
}
return result;
} catch (e) {
lastError = e as Error;
if (e instanceof FotohubAPIError) {
const modelErrors = ["model_unavailable", "model_overloaded", "provider_error", "timeout"];
if (modelErrors.includes(e.code)) {
console.warn(`Model ${currentModel} unavailable [${e.code}], trying fallback...`);
continue;
}
}
// Non-model error — do not fallback
throw e;
}
}
throw lastError;
}
// Usage
const result = await generateWithFallback("seedream-5-0-260128", "A mountain at sunset", {
width: 1024,
height: 1024,
});
console.log(`URL: ${result.url}`);package main
import (
"fmt"
"strings"
)
var fallbackModels = map[string]string{
"seedream-5-0-260128": "flux-2-klein-4b",
"kling-v3": "veo-3.1-generate-001",
}
var modelErrorCodes = map[string]bool{
"model_unavailable": true,
"model_overloaded": true,
"provider_error": true,
"timeout": true,
}
func generateWithFallback(model, prompt string, opts map[string]interface{}) (map[string]interface{}, error) {
modelsToTry := []string{model}
if fallback, ok := fallbackModels[model]; ok {
modelsToTry = append(modelsToTry, fallback)
}
var lastErr error
for _, currentModel := range modelsToTry {
payload := map[string]interface{}{
"model": currentModel,
"prompt": prompt,
}
for k, v := range opts {
payload[k] = v
}
result, err := requestWithRetry("POST", "/ai/generate/image", payload)
if err == nil {
if currentModel != model {
fmt.Printf("Used fallback model: %s (primary: %s)\n", currentModel, model)
}
return result, nil
}
lastErr = err
// Check if it's a model-related error worth falling back from
errStr := err.Error()
isModelError := false
for code := range modelErrorCodes {
if strings.Contains(errStr, code) {
isModelError = true
break
}
}
if isModelError {
fmt.Printf("Model %s unavailable, trying fallback...\n", currentModel)
continue
}
// Non-model error — do not fallback
return nil, err
}
return nil, lastErr
}
func main() {
result, err := generateWithFallback("seedream-5-0-260128", "A mountain at sunset",
map[string]interface{}{"width": 1024, "height": 1024})
if err != nil {
fmt.Printf("All models failed: %v\n", err)
return
}
fmt.Printf("URL: %s\n", result["url"])
}#!/bin/bash
# Generate with model fallback on provider errors
API_BASE="https://apis.fotohub.app/v1"
API_KEY="fh_live_your_api_key"
generate_with_fallback() {
local primary_model="$1"
local fallback_model="$2"
local prompt="$3"
# Try primary model
local response http_code body
response=$(curl -s -w "\n%{http_code}" \
-X POST "$API_BASE/ai/generate/image" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\": \"$primary_model\", \"prompt\": \"$prompt\", \"width\": 1024, \"height\": 1024}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 400 ] 2>/dev/null; then
echo "$body"
return 0
fi
# Check if error is model-related (worth trying fallback)
local error_code
error_code=$(echo "$body" | jq -r '.error // ""')
case "$error_code" in
model_unavailable|model_overloaded|provider_error|timeout)
if [ -n "$fallback_model" ]; then
echo "Primary model $primary_model unavailable, trying $fallback_model..." >&2
response=$(curl -s -w "\n%{http_code}" \
-X POST "$API_BASE/ai/generate/image" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\": \"$fallback_model\", \"prompt\": \"$prompt\", \"width\": 1024, \"height\": 1024}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 400 ] 2>/dev/null; then
echo "Used fallback model: $fallback_model" >&2
echo "$body"
return 0
fi
fi
;;
esac
echo "Error: $body" >&2
return 1
}
# Usage
generate_with_fallback "seedream-5-0-260128" "flux-2-klein-4b" "A mountain at sunset"Validation Errors (400/422)
Validation errors indicate a problem with the request parameters. The details.fields array tells you exactly which parameters are invalid and why.
def handle_validation_error(error: FotohubAPIError) -> dict:
"""
Parse validation errors and return structured feedback.
Useful for building user-facing form validation.
"""
fields = error.details.get("fields", [])
field_errors = {}
for field_info in fields:
field_name = field_info.get("field", "unknown")
reason = field_info.get("reason", "Invalid value")
field_errors[field_name] = reason
return field_errors
# Usage — building a generation form
try:
result = request_with_retry("POST", "/ai/generate/image", {
"model": "seedream-5-0-260128",
"prompt": "", # Empty — will trigger validation error
"width": 5000, # Too large — will trigger validation error
})
except FotohubAPIError as e:
if e.error == "invalid_parameters":
field_errors = handle_validation_error(e)
for field, reason in field_errors.items():
print(f" {field}: {reason}")
# Output:
# prompt: Prompt must not be empty
# width: Value 5000 exceeds maximum of 2048
elif e.error == "missing_required_field":
missing = e.details.get("field", "unknown")
print(f"Missing required field: {missing}")interface FieldError {
field: string;
reason: string;
}
function handleValidationError(
error: FotohubAPIError
): Record<string, string> {
/**
* Parse validation errors into a field -> message map.
* Useful for form validation UI.
*/
const fields = (error.details?.fields as FieldError[]) ?? [];
const fieldErrors: Record<string, string> = {};
for (const { field, reason } of fields) {
fieldErrors[field] = reason;
}
return fieldErrors;
}
// Usage — building a generation form
try {
const result = await requestWithRetry("POST", "/ai/generate/image", {
model: "seedream-5-0-260128",
prompt: "", // Empty
width: 5000, // Too large
});
} catch (e) {
if (e instanceof FotohubAPIError) {
if (e.code === "invalid_parameters") {
const fieldErrors = handleValidationError(e);
for (const [field, reason] of Object.entries(fieldErrors)) {
console.error(` ${field}: ${reason}`);
}
// Show errors next to form fields in UI
} else if (e.code === "missing_required_field") {
const missing = (e.details?.field as string) ?? "unknown";
console.error(`Missing required field: ${missing}`);
}
}
}package main
import (
"encoding/json"
"fmt"
)
type FieldError struct {
Field string `json:"field"`
Reason string `json:"reason"`
}
func handleValidationError(details map[string]interface{}) map[string]string {
fieldErrors := make(map[string]string)
fieldsRaw, ok := details["fields"]
if !ok {
return fieldErrors
}
// Re-marshal and unmarshal to parse the nested structure
fieldsJSON, _ := json.Marshal(fieldsRaw)
var fields []FieldError
json.Unmarshal(fieldsJSON, &fields)
for _, f := range fields {
fieldErrors[f.Field] = f.Reason
}
return fieldErrors
}
// Usage
func example() {
_, err := requestWithRetry("POST", "/ai/generate/image", map[string]interface{}{
"model": "seedream-5-0-260128",
"prompt": "", // Empty
"width": 5000, // Too large
})
if err != nil {
// Parse error details (simplified for example)
fmt.Printf("Validation error: %v\n", err)
// In production, parse the error body for field-level details
}
}#!/bin/bash
# Handle 400/422 validation errors
response=$(curl -s -w "\n%{http_code}" \
-X POST "https://apis.fotohub.app/v1/ai/generate/image" \
-H "Authorization: Bearer $FOTOHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "seedream-5-0-260128", "prompt": "", "width": 5000}')
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" = "400" ] || [ "$http_code" = "422" ]; then
error_code=$(echo "$body" | jq -r '.error')
if [ "$error_code" = "invalid_parameters" ]; then
echo "Validation errors:" >&2
echo "$body" | jq -r '.details.fields[] | " \(.field): \(.reason)"' 2>/dev/null
elif [ "$error_code" = "missing_required_field" ]; then
field=$(echo "$body" | jq -r '.details.field')
echo "Missing required field: $field" >&2
elif [ "$error_code" = "unsupported_format" ]; then
echo "Unsupported file format. Use JPEG, PNG, WebP, MP4, or MP3." >&2
fi
exit 1
fiError Handling Best Practices
Always check error codes programmatically
Use the error field (not message) for control flow. Messages may change between versions; error codes are stable and part of the API contract.
Log request_id with every call
Store request_id in your logs for every request. It is the fastest way to get help from support and enables end-to-end request tracing.
Use idempotency keys for mutations
Any request that charges your wallet or creates resources should include the X-Idempotency-Key header. This prevents duplicate operations — and duplicate charges — when retrying after timeouts or network errors.
Implement circuit breakers
After 5+ consecutive 5xx errors, pause requests for 30 seconds to avoid overwhelming a recovering service. This protects your application from cascading failures.
Set per-operation timeouts
Different operations have different expected durations. Configure timeouts accordingly:
| Operation | Recommended Timeout |
|---|---|
| Image generation | 30s |
| Video generation | 120s |
| Chat / text generation | 30s |
| Image analysis | 15s |
| Audio generation | 60s |
| File upload | 30s |
Handle 402 gracefully in UI
When the wallet runs out, show a clear path to top up rather than a generic error page. detail.shortfall_usd is the exact minimum that makes the request go through, and detail.topup_url links straight to the wallet. Nothing was charged, so the operation can be retried unchanged once the balance is there.
Better still, do not reach the 402: check POST /v1/billing/estimate before a batch, and watch billing.balance_usd on each successful response — every billed response reports the balance left after the charge, so a UI can warn before the wallet is empty rather than after.
Use model fallbacks for production
Always configure a fallback model for generation endpoints. If the primary model is unavailable (503) or overloaded, transparently switch to an alternative to maintain service uptime.
Implement graceful degradation
When the circuit breaker opens or all retries are exhausted, serve cached content, show a maintenance message, or queue the operation for later — do not show a raw error to end users.
TIP
The official SDKs (Python and TypeScript) handle retries, idempotency, and error classification automatically. If you are building a new integration, start with the SDK rather than raw HTTP — see the SDKs section for installation and usage.
Related APIs
- Rate Limits -- Tier limits, headers, and backoff strategies.
- Webhooks -- Receive async notifications instead of polling.
- Authentication -- API key management, scopes, and rotation.

