# Errors

When a request fails, the API returns a non-2xx status code and a JSON body with an `error` message. Every endpoint follows the same rules, so one error handler covers the whole API.

## Error format

The body of an error response is a JSON object with a human-readable string:

```json
{ "error": "The 'query' field must be a non-empty string." }
```

Messages are written for developers. Log them and show them in internal tools, but base your control flow on the HTTP status rather than the exact wording.

When a validation failure has more than one cause, the body also carries `details`, with one entry per problem:

| Field | Shape |
| --- | --- |
| `details` | An array of `{ path, message }` objects. `path` names the field, such as `config.storeUrl` or `variants[0].available`. Item upload adds `index`, the product's position in your request, or `-1` for request-wide problems. |

```json
{
  "error": "Validation failed.",
  "details": [
    { "index": 0, "path": "price", "message": "Price must be a non-negative number." },
    { "index": 2, "path": "variants[0].available", "message": "Expected boolean." }
  ]
}
```

A successful status never carries an error. If you receive a `2xx`, the request did what it says.

Metered endpoints include the `X-RateLimit-*` headers on error responses as well. See [Rate limits and quotas](rate-limits).

## Status codes

| Status | Meaning |
| --- | --- |
| 400 | The request body is not valid JSON, or is not a JSON object. |
| 401 | The API key is missing, malformed, the wrong type, not recognized, or belongs to an organization that no longer exists. |
| 403 | The key is valid, but the organization has no active subscription, no quota record, or no access to this endpoint on its plan; or the key is limited to websites or IP addresses and the request came from somewhere else. |
| 404 | The resource in the path doesn't exist, or belongs to another organization. |
| 409 | The request conflicts with existing state, such as starting a sync on a data source that is not active. |
| 410 | The endpoint has been retired. |
| 413 | The request is too large, such as more than 5,000 products in one upload. |
| 415 | An uploaded file's type is not accepted. |
| 422 | The request is well-formed, but a body field, query parameter or path parameter failed validation. |
| 429 | A quota or requests-per-minute limit was hit. Check `Retry-After`. |
| 500 | Something failed on the server. The message is always `Internal server error.` |
| 502 | The stylist's AI model failed or returned output that could not be read. |
| 503 | A dependency isn't ready yet, such as a catalogue index being rebuilt. Retry shortly. |

Any endpoint can also return the shared authentication, entitlement, rate-limit and server errors. Those are listed with each endpoint and explained in [Authentication](authentication) and [Rate limits and quotas](rate-limits).

## Responses that report per-part results

Two kinds of response describe outcomes more finely than a single status can.

### Multi-file uploads

Image upload returns `200` when every file was stored, `207 Multi-Status` when some were, and an error status when none were. The body always contains `files`, `failures` and `summary`, so read `failures` whenever the status is `207`.

### Streaming responses

Once a streaming response has started, its HTTP status has already been sent as `200`. A failure after that point arrives as an `error` event inside the stream, with the same `{ "error": "..." }` body. Failures before the stream starts, such as validation or rate limits, are ordinary error responses. See [Streaming](streaming).

## Handling errors in JavaScript

This helper reads the body once and exposes the status, message, validation details and retry hint:

```javascript
class StylorError extends Error {
  constructor(status, body, headers) {
    super(body?.error ?? `HTTP ${status}`);
    this.name = "StylorError";
    this.status = status;
    this.body = body;
    this.details = body?.details ?? null;
    const retryAfter = headers.get("Retry-After");
    this.retryAfterSeconds = retryAfter ? Number(retryAfter) : null;
  }

  get isRetryable() {
    return this.status === 429 || this.status >= 500;
  }
}

async function stylor(path, { method = "GET", body, key = process.env.STYLOR_API_KEY } = {}) {
  const res = await fetch(`https://stylor.ai/api${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${key}`,
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  const text = await res.text();
  let data = null;
  try {
    data = text ? JSON.parse(text) : null;
  } catch {
    data = { error: text || res.statusText };
  }

  if (!res.ok) throw new StylorError(res.status, data, res.headers);
  return data;
}

try {
  const { dataset } = await stylor(`/v1/datasets/${datasetId}`);
  console.log(dataset.name);
} catch (err) {
  if (err instanceof StylorError) {
    console.error(err.status, err.message, err.details ?? "");
    if (err.isRetryable && err.retryAfterSeconds) {
      // Wait before retrying. See the rate limits guide for a backoff helper.
    }
  } else {
    throw err;
  }
}
```

## Handling errors in Python

The same approach with `requests`:

```python
import os
import requests

BASE_URL = "https://stylor.ai/api"


class StylorError(Exception):
    def __init__(self, status, body, headers):
        body = body or {}
        super().__init__(body.get("error") or f"HTTP {status}")
        self.status = status
        self.body = body
        self.details = body.get("details")
        retry_after = headers.get("Retry-After")
        self.retry_after_seconds = int(retry_after) if retry_after else None

    @property
    def is_retryable(self):
        return self.status == 429 or self.status >= 500


def stylor(path, method="GET", json=None, key=None):
    key = key or os.environ["STYLOR_API_KEY"]
    res = requests.request(
        method,
        f"{BASE_URL}{path}",
        headers={"Authorization": f"Bearer {key}"},
        json=json,
        timeout=60,
    )

    try:
        data = res.json() if res.content else None
    except ValueError:
        data = {"error": res.text or res.reason}

    if not res.ok:
        raise StylorError(res.status_code, data, res.headers)
    return data


try:
    dataset = stylor(f"/v1/datasets/{dataset_id}")["dataset"]
    print(dataset["name"])
except StylorError as err:
    print(err.status, err, err.details or "")
```

## Retrying safely

- Retry `429`, `500`, `502` and `503` responses after a delay, honoring `Retry-After` when it is present.
- Don't retry `400`, `401`, `403`, `404`, `409`, `410`, `413`, `415` or `422` without changing the request.
- Item uploads and sync job creation are safe to repeat. A retried upload whose first attempt succeeded reports its products as `unchanged`, and creating a sync job returns the job already in flight.
