Every error SurrealDB returns has the same shape, whichever protocol carries it. The SDKs map that shape onto their own idioms, so the vocabulary on this page is the one behind every SDK's error type.
The preferred way to handle an error is to branch on kind first and code second, both of which are a stable contract. The message is written for a person reading it and is free to change between releases, so treat it as text to display rather than something to match on.
Where an error appears
A request has two layers that can fail independently, and they report failure differently.
The call can fail as a whole: incorrect syntax, an unknown method, a rejected sign-in. In this case the response will carry an error object in place of a result.
{
"error": {
"cause": null,
"code": -32603,
"details": { "kind": "InvalidParams" },
"kind": "Validation",
"message": "Expected (what, data)"
}
}Once the call succeeds, individual statements inside will be either successes or failures. Each statement reports its own status, and a failing one carries its kind with the message in result. Statements that ran before it keep their results.
Take these three statements for example which can be sent as one call. The field definition and the first CREATE statement are accepted, and only the third breaks the assertion:
DEFINE FIELD name ON user TYPE string ASSERT $value.len() <= 20;
CREATE user:short SET name = "Billy";
CREATE user:long SET name = "Mr. Muchtoolongname the Fourth";The call itself succeeded, so there is no error object. The failure is reported against the one statement that caused it, and user:short is still created:
{
"result": [
{ "result": null, "status": "OK", "time": "7.899708ms", "type": null },
{
"result": [{ "id": "user:short", "name": "Billy" }],
"status": "OK",
"time": "10.369375ms",
"type": null
},
{
"kind": "Internal",
"result": "Found 'Mr. Muchtoolongname the Fourth' for field `name`, with record `user:long`, but field must conform to: $value.len() <= 20",
"status": "ERR",
"time": "1.351458ms",
"type": null
}
]
}A failed statement does not stop the ones after it, and does not roll back the ones before it. Statements are independent unless a transaction makes them otherwise.
Inside a transaction
Wrapping the same two CREATE statements in BEGIN and COMMIT ties their fates together:
DEFINE FIELD name ON user TYPE string ASSERT $value.len() <= 20;
BEGIN;
CREATE user:short SET name = "Billy";
CREATE user:long SET name = "Mr. Muchtoolongname the Fourth";
COMMIT;The call still succeeds, so there is still no error object. What changes is that the statement which would have worked on its own now reports NotExecuted, and the COMMIT refuses:
{
"result": [
{ "result": null, "status": "OK", "time": "8.304667ms", "type": null },
{ "result": null, "status": "OK", "time": "0ns", "type": null },
{
"details": { "kind": "NotExecuted" },
"kind": "Query",
"result": "The query was not executed due to a failed transaction",
"status": "ERR",
"time": "10.880333ms",
"type": null
},
{
"kind": "Internal",
"result": "Found 'Mr. Muchtoolongname the Fourth' for field `name`, with record `user:long`, but field must conform to: $value.len() <= 20",
"status": "ERR",
"time": "923.041µs",
"type": null
},
{
"details": { "kind": "NotExecuted" },
"kind": "Query",
"result": "Cannot COMMIT: the transaction was aborted due to a prior error",
"status": "ERR",
"time": "0ns",
"type": null
}
]
}Neither record exists afterwards, user:short included. The DEFINE FIELD is untouched, because it ran before BEGIN: only the statements inside the transaction are rolled back.
This split is why each SDK offers two ways to read a query result: one that surfaces the first failure, and one that reports every statement. The names differ per language, and each SDK's error page covers its own.
Over HTTP
The HTTP endpoints /sql and /key use the same two layers. When the request itself fails, the response has an HTTP error status: 400 for a parse error, 401 for credentials that do not authenticate, and 413 for a body over the size limit. Once the request is accepted, the response is 200 OK even when a statement fails. This includes a THROW, a record that already exists, a namespace or table that does not exist, and a statement that runs past --query-timeout. Each failure is reported in that statement's entry in the response array, with status set to "ERR":
curl -X POST -u "root:secret" -H "Accept: application/json" -d 'THROW "Order not found"' http://localhost:8000/sql[
{
"kind": "Thrown",
"result": "An error occurred: Order not found",
"status": "ERR",
"time": "101.75µs",
"type": null
}
]A GET /key/:table/:id for a record that does not exist is not an error. It returns 200 with an empty result array. Code that calls these endpoints must therefore check status on each entry as well as the HTTP status.
The error object
| Field | Description |
|---|---|
kind | The error category. The primary thing to branch on. |
code | Numeric wire code, kept for backwards compatibility. |
message | A human-readable description liable to change. Be sure not to match on it unless you are able to update the match when upgrading versions. |
details | Structured detail for kinds that carry one, itself carrying a nested kind. |
cause | The underlying error, where one was attached. Nested errors use this same shape. |
Error kinds
| Kind | Meaning |
|---|---|
Validation | Parse error, invalid request, or invalid parameters |
Configuration | A feature or configuration is not supported |
Query | A query timed out, was cancelled, or was not executed |
Serialization | A value could not be serialised or deserialised |
NotAllowed | A permission or authorisation check failed |
NotFound | A resource does not exist |
AlreadyExists | A resource already exists |
Connection | A client-side connection failure |
Thrown | A THROW statement ran in SurrealQL |
Internal | An internal or unexpected failure |
Context | A wrapper carrying context around another error |
Internal doubles as the catch-all for a kind the reader does not recognise, so code that handles the kinds it cares about and treats the rest as internal keeps working against a newer server.
Error details
details names the specific reason within the kind. It has its own kind, and its own details where the reason carries values. Details nest, so a failed sign-in over RPC is a NotAllowed error whose reason is Auth, whose reason in turn is InvalidAuth:
{
"kind": "NotAllowed",
"code": -32002,
"message": "There was a problem with authentication",
"details": {
"kind": "Auth",
"details": { "kind": "InvalidAuth" }
},
"cause": null
}A reason with values carries them in the inner details. A duplicate record, for example, gives { "kind": "AlreadyExists", "details": { "kind": "Record", "details": { "id": "user:a" } } }.
| Kind | Reason | Values |
|---|---|---|
Validation | Parse, InvalidRequest, InvalidParams, NamespaceEmpty, DatabaseEmpty | |
Validation | InvalidParameter | name |
Validation | InvalidContent, InvalidMerge | value |
Configuration | LiveQueryNotSupported, BadLiveQueryConfig, BadGraphqlConfig, DialectNotSupported (from 3.3.0) | |
Query | NotExecuted, Cancelled, TransactionConflict | |
Query | TimedOut | duration |
Serialization | Serialization, Deserialization | |
NotAllowed | Scripting | |
NotAllowed | Method, Function, Target | name |
NotAllowed | Auth | A further reason, listed below |
NotFound | Method, Table, Namespace, Database | name |
NotFound | Record | id |
NotFound | Session | id, which can be empty |
NotFound | Transaction | |
AlreadyExists | Table, Namespace, Database | name |
AlreadyExists | Record, Session | id |
Connection | Uninitialised, AlreadyConnected, ConnectionFailed |
Connection errors are raised by an SDK on the client, not sent by the server. Thrown, Internal and Context carry no reason.
The reasons inside Auth are:
| Reason | Values |
|---|---|
TokenExpired, SessionExpired, InvalidAuth, UnexpectedAuth, MissingUserOrPass, NoSigninTarget, InvalidPass, TokenMakingFailed, InvalidSignup | |
InvalidRole | name |
NotAllowed | actor, action, resource |
A few reasons tell a client what to do next. TransactionConflict means that the operation can be retried. TokenExpired and SessionExpired mean that the client needs to sign in again.
Not every error carries a reason. A parse error sent over RPC, for example, arrives as a Validation error with no details, so check that details is present before reading it. New reasons are added as the server grows, so treat a reason you do not recognise as an error of its kind, in the same way as an unknown kind is treated as Internal.
Wire codes
| Code | Name |
|---|---|
-32700 | Parse error |
-32600 | Invalid request |
-32601 | Method not found |
-32602 | Method not allowed |
-32603 | Invalid parameters |
-32604 | Live query not supported |
-32605 | Bad live query configuration |
-32606 | Bad GraphQL configuration |
-32000 | Internal error |
-32001 | Client-side error |
-32002 | Invalid authentication |
-32003 | Query not executed |
-32004 | Query timed out |
-32005 | Query cancelled |
-32006 | Thrown |
-32007 | Serialization error |
-32008 | Deserialization error |
-32009 | Query transaction conflict |
-32607 | Dialect not supported (from 3.3.0) |
Two of these differ from the JSON-RPC conventions they resemble: -32602 is method-not-allowed rather than invalid parameters, and invalid parameters is -32603. A code also does not always follow from the kind, since an error can be a Validation while carrying the generic -32000. Reading kind avoids both surprises.
In the SDKs
Each SDK exposes these kinds through its own error type, and documents the two-layer behaviour in its own idiom.