# Errors

Every error is JSON with an `error` string and, where it helps, a `hint`:

```json
{ "error": "too many urls", "hint": "max 500 per request, got 501" }
```

## HTTP statuses

| status | when |
|---|---|
| 400 | bad input — missing `url`, unparseable body, too many items, unrecognized platform, `days` out of range |
| 401 | missing, unknown or disabled key; admin endpoint without the admin token |
| 403 | scoped key reaching outside its scopes (`/v1/raw` only) |
| 404 | the post or profile does not exist, is deleted, or is private — **never** a zeroed result |
| 405 | wrong method (e.g. `GET` on `/v1/posts`) |
| 502 | upstream unreachable, or upstream returned an error we could not turn into a 404 |

## 404 is a feature

The upstream provider answers HTTP 200 for a deleted post and simply omits the post object. Read naively, a removed video looks like a genuine crash to 0 views — which, if the numbers feed view tracking, is a payout dispute waiting to happen.

TikData checks for the object explicitly and answers 404 instead. The same applies to profiles: X returns 200 with `status: "notfound"` and Instagram returns 200 with `status: false` for accounts that do not exist, and both become 404 here.

Missing *individual* stats still come back as `0` inside an otherwise-successful response — platforms omit different fields, and a partial answer beats a failed refresh.

## Private vs deleted

Not distinguishable through the upstream provider. Verified 2026-09-08 against a known-private TikTok video across seven endpoints: every one returns a byte-identical body for private and for a bad id. The signal exists at TikTok itself (`statusMsg: "status_self_see"` vs `"item doesn't exist"`) but is stripped before it reaches us. See [videos/bulk_stats](/docs/videos-bulk-stats.md) for the per-item error codes this leaves us with.

## Per-item errors in batch endpoints

`POST /v1/posts` and `POST /v1/videos/bulk_stats` never fail the whole request for one bad item. Each result carries `ok: true|false`, and failed items carry an `error` code:

| code | meaning |
|---|---|
| `not_found` | well-formed reference, but the platform returned no post (deleted, private, or never existed) |
| `invalid_id` | the id is malformed — TikTok ids encode a creation timestamp, so this is decided offline with no upstream call |
| `unsupported_platform` | `platform` was given but is not `tiktok`, `instagram`, `youtube` or `x` |
| `bad_input` | neither a usable `id` nor `url`, or no platform could be determined |
| `upstream_error` | the provider returned a non-2xx we could not classify |
| `upstream_unreachable` | timeout or network failure reaching the provider |

## Upstream flakiness, and how TikData handles it

The provider fails in two different ways, and they need opposite responses.

**The node wobbles.** 5xx, dropped connections and the provider's own `400 "Request failed. Please retry."` arrive in bursts that hit every endpoint and every platform at once — a run of ~100 calls pushed the whole account into 502s for about 40 seconds on 2026-09-12, TikTok, X, Instagram and YouTube alike. Switching endpoints cannot help with that; only waiting can. So these **retry the same endpoint** with backoff (2 retries, 400 ms growing).

**One backend goes blind.** A `200` arrives with the post object simply missing, while a sibling endpoint on a different backend still has it. Retrying the same path is pointless. So these **move to the next endpoint** in the chain.

### The chains

Every entry was verified live against a known-good subject on 2026-09-12.

| operation | TikTok | Instagram | YouTube | X |
|---|---|---|---|---|
| post | 3 backends | 2 (`v3` at $0.008 last) | 2 | 1 — retry only |
| profile | 1 — retry only | 3 | 1 | 1 — retry only |
| feed | 3 | 3 | 1 | 2 |

Where the table says "1", no working alternate exists on this node: the siblings are 404, or answer 200 without the field that matters (X's `fetch_user_about` returns no follower count, so it cannot stand in for `fetch_user_profile`).

### What a failed lookup costs

A blind backend is indistinguishable from a genuinely deleted post — both answer 200-with-nothing. Walking the whole chain is what separates them: if every backend says nothing, it really is gone. So a deleted TikTok post costs 3 calls rather than 1. That is the price of never reporting a live post as deleted, and malformed ids are still rejected offline for free as `invalid_id`.

Feed walks pick a backend on page one and stay on it, because paging cursors are endpoint-specific.

### Seeing which backend answered

`/v1/post` and `/v1/profile` return two headers:

| header | meaning |
|---|---|
| `x-router-source` | the upstream path that produced the data |
| `x-router-attempts` | total upstream requests made, including retries and fallbacks |

`/v1/creator/stats` reports the same thing in its `source` field.
