Core concept
Errors
What the API returns when something goes wrong, how each SDK surfaces it, and which failures are worth handling rather than logging.
Every failure carries a code
The message is for humans. The code is for your code.
{
"success": false,
"message": "This record changed since you last read it. Re-read it and retry.",
"code": "VERSION_CONFLICT",
"expectedVersion": 1,
"currentVersion": 9
}Branch on code, never on message — messages get reworded, codes do not. Some errors carry extra fields, like currentVersion above, which is what lets you retry instead of just reporting.
The codes
Sorted by what you should do about them.
| Code / status | Meaning | What to do |
|---|---|---|
| VERSION_CONFLICT | 409 | Someone wrote first. Nothing was saved. Re-read and retry — this one is genuinely recoverable. |
| NO_USER_SESSION | 401 | The endpoint acts on the signed-in user and nobody is signed in. Send them to login. |
| (none) | 401 | Wrong password, or an expired or revoked credential. Show a sign-in error. |
| INSUFFICIENT_SCOPE | 403 | A publishable key on a secret-key endpoint. This is a bug in your integration, not something the user can fix — move the call to your server. |
| (none) | 403 | Authenticated but not permitted — usually an unverified account, or a plan limit reached. The message says which. |
| (none) | 404 | The app, user or record does not exist under this account. Often a stale id. |
| (none) | 400 | Rejected as invalid, including filtering on a field that is not searchable. Fix the request. |
| (none) | 429 | Rate limited, or the monthly quota is exhausted. Back off; resetDate says when it clears. |
| (none) | 5xx | The API failed. Retry with backoff — but only idempotent calls. |
How each SDK surfaces them
Same codes, different idioms.
1try {
2 await authix.updateUserData({ userId, dataId, data, version });
3} catch (error) {
4 switch (error.code) {
5 case "VERSION_CONFLICT":
6 return retryWith(error.currentVersion);
7 case "NO_USER_SESSION":
8 return sendToLogin();
9 case "INSUFFICIENT_SCOPE":
10 // Our bug: this call needs a secret key and belongs on the server.
11 throw error;
12 default:
13 showError(error.message);
14 }
15}Python and Dart map each code to a distinct exception class, so you catch the case you handle and let the rest surface. JavaScript throws an object carrying status, code and message.
Three failures worth handling explicitly
Everything else can share one “something went wrong” path.
- 409 — version conflict. The only error that is routinely correct to retry. Ignoring it means silently losing a write.
- 401 with NO_USER_SESSION. The session expired or was revoked mid-use. Your route guard still says “signed in”, so nothing else will notice.
- 403 with INSUFFICIENT_SCOPE. Never show this to a user — it means your code used the wrong key. Log it loudly and treat it as a deployment bug.
Retrying a conflict properly
Re-read, reapply, retry — do not just resend the same body.
1async function updateWithRetry(userId, dataId, change, attempts = 3) {
2 for (let attempt = 0; attempt < attempts; attempt++) {
3 const current = await authix.getSingleUserData({ userId, dataId });
4
5 try {
6 return await authix.updateUserData({
7 userId,
8 dataId,
9 data: change(current.data),
10 version: current.data.version,
11 });
12 } catch (error) {
13 // Anything other than a conflict is not going to fix itself.
14 if (error.code !== "VERSION_CONFLICT") throw error;
15 }
16 }
17
18 throw new Error("Gave up after " + attempts + " conflicting writes");
19}Reapply, do not replay
Resending the original payload with the new version defeats the purpose: you overwrite the other writer's change, which is exactly what the version check existed to prevent. Recompute your change from the record you just re-read.
Two or three attempts is plenty. Beyond that the record is genuinely contended and the right answer is usually a batch or a rethink of the write path.
What not to do
- Collapsing everything into a 500 at your own API boundary. A caller can act on a 409 and can do nothing at all with a 500. Pass the status through.
- Forwarding INSUFFICIENT_SCOPE to your users. It describes your server's configuration, not their request.
- Catching everything and continuing. A swallowed 403 on a write looks identical to a successful save until someone refreshes.
- Retrying non-idempotent calls on a 5xx. A create that timed out may well have succeeded.
- Matching on message text. Wording changes; codes are the contract.
Network failures are not API errors
A timeout or DNS failure means the request may never have reached the server — or may have been processed with the response lost. Treat it as “unknown”, not as “failed”. Every SDK surfaces these separately (NetworkError, or status: 0) precisely so you can tell the difference.
Related