Errors
Seven codes, and that is the whole set. Every failed request answers with the same envelope, whatever route it hit:
{ "error": { "code": "VALIDATION_FAILED", "message": "Validation failed", "requestId": "req_01J8Z…", "fields": [ { "path": "set.note", "message": "every patch path must start with \"custom.\" — got \"note\"" } ] }}requestId is worth logging: it is what identifies a single request in support.
The codes
Section titled “The codes”| Code | HTTP | Typical cause | Retry? |
|---|---|---|---|
VALIDATION_FAILED |
400 | Malformed body — a custom over 64 KB, a refs entry that is not type:id, a patch path outside custom.. fields says which. |
No. It will fail identically forever. |
UNAUTHENTICATED |
401 | Missing, malformed, expired or revoked token; wrong apiKey. |
Once, after minting a fresh token. The client SDK already does this for you. |
FORBIDDEN |
403 | A valid token for the wrong thing — a user token writing another user’s feed, patching an activity that is not its own, or calling a server-only route (an object write, or any batch.*). Object reads are open to any token. |
No. Fix the token kind or the target. |
NOT_FOUND |
404 | No such activity, object, or reaction. | No. |
CONFLICT |
409 | The (foreign_id, time) identity you posted was deleted. The identity is burned; a live duplicate is not an error (see below). |
No. Pick a new foreign_id or time. |
RATE_LIMITED |
429 | A rate-limit window is full. | Yes — after sleeping retryAfterSeconds. |
INTERNAL |
500 | Our fault. Also the fallback for any status not in this table (a 502 from a proxy in front of us). | Yes, with backoff. Not a poison pill — an INTERNAL that repeats identically for the same body is worth reporting with its requestId. |
Auth failures never say more than UNAUTHENTICATED or FORBIDDEN. The specific
reason is logged on our side and deliberately not returned — telling a caller which
check failed is a gift to someone probing.
What the SDKs throw
Section titled “What the SDKs throw”Both SDKs throw the same class, DropInApiError, built from the same parse:
import { DropInApiError } from '@dropinnodex/server' // or '@dropinnodex/client'
try { await dropin.feed('user', 'alice').addActivity({ verb: 'post', object: 'w:1' })} catch (err) { if (!(err instanceof DropInApiError)) throw err // a network failure or an abort
err.code // 'RATE_LIMITED' — branch on this, never on the message err.status // 429 err.requestId // 'req_01J8Z…' — log it err.retryAfterSeconds // 30 on a 429, undefined otherwise err.fields // [{ path, message }] on a VALIDATION_FAILED that had detail err.url // the request that failed — the message only carries what we said}err instanceof DropInApiError means the API answered. An aborted request rejects
with an AbortError and a network failure with whatever fetch throws — neither is a
DropInApiError, so a catch that only inspects .code should re-throw anything else.
A retry policy that matches this table
Section titled “A retry policy that matches this table”const RETRYABLE = new Set(['RATE_LIMITED', 'INTERNAL'])
async function emit(fn: () => Promise<void>, attempts = 3) { for (let i = 1; ; i++) { try { return await fn() } catch (err) { const retryable = err instanceof DropInApiError && RETRYABLE.has(err.code) if (!retryable || i === attempts) throw err const wait = err.retryAfterSeconds ?? 2 ** i await new Promise((r) => setTimeout(r, wait * 1000)) } }}Make the retry safe before you make it aggressive: send foreign_id and time on
every activity, and a replay dedupes into the original instead of double-posting. See
Idempotency.
Two things that are not errors
Section titled “Two things that are not errors”- A duplicate
(foreign_id, time)write. It returns201with the existing activity, unchanged, and fans out nothing. That is what makes retries safe. - An activity whose
actorwas never upserted. The write succeeds — butactor_userisnulland the response carrieswarnings: ["actor_user_unresolved"]. See Add an Activity.
Is it us, or is it you?
Section titled “Is it us, or is it you?”An INTERNAL at 2am is only useful if you can answer that in one step.
Start here: status.getnodex.cloud. It probes the API
from your own browser and from a second network, so it can tell “we are down” apart from
“you cannot reach us” — and it is hosted away from our infrastructure, so it still answers
when everything else of ours is gone. Every INTERNAL we return names it, too.
If you would rather have the raw signal, or you are scripting this, the same information is two unauthenticated endpoints — no key, no token, safe to curl from anywhere or point an uptime monitor at:
# Liveness. `version` is the git commit of the running build, so you can tell# whether behaviour changed because we deployed.curl https://api.getnodex.cloud/v1/health# {"status":"ok","version":"45f4f5f…"}
# Readiness — 200 when every dependency answers, 503 when one doesn't.curl https://api.getnodex.cloud/v1/health/ready# {"status":"ready","checks":[{"name":"postgres","ok":true},{"name":"redis","ok":true},{"name":"feed","ok":true}]}How to read it:
/v1/health/readyis 503 — it’s us, andchecksnames which dependency. Nothing you change on your side will help; retry with backoff and wait.- It’s 200 and your call still fails — it’s reaching us and we’re answering, so the
request itself is the suspect. Send the
requestIdfrom the error rather than a description; that’s what identifies the single request in our logs. versionchanged since your last good call — worth mentioning in the report, even if you don’t suspect it.
Use these for triage and monitoring, not as a gate. Health-checking before every write doubles your round trips and tells you nothing the write itself won’t.