Skip to content

Concepts

Error handling

The Python SDK raises an exception from one class hierarchy for every failed operation, from a refused query to a server that does not answer. This page shows which class each kind of failure raises, how to catch the ones your code can handle, and which ones are worth retrying. The error kinds, wire codes and structured shape behind server errors are documented once for every SDK and protocol in Errors.

Class or method

Description

SurrealError

Base class for every error the SDK raises

ServerError

A failure the server reported, with a kind, details and a cause chain

TransportError

A request that got no answer from the server

HttpStatusError

An HTTP error status, such as 401 for a refused token

UnsupportedFeatureError

A feature the connection type does not have

UnavailableFeatureError

A feature the server version does not have

ErrorKind

Constants for the kind of a server error

db.query_raw()

Returns the status of each statement in place of raising

Every exception the SDK raises for a failed operation is a subclass of SurrealError, so except SurrealError catches all of them. Below it, two branches separate a failure that the server reported from a request that got no answer:

SurrealError
├── ServerError
│   ├── ValidationError, ConfigurationError, ThrownError
│   ├── QueryError, SerializationError, NotAllowedError
│   └── NotFoundError, AlreadyExistsError, InternalError
├── TransportError
│   ├── ConnectionUnavailableError
│   ├── TransportTimeoutError
│   └── HttpStatusError
├── UnsupportedFeatureError
│   └── UnavailableFeatureError
├── UnsupportedVersionError, UnsupportedEngineError
├── InvalidUrlError, ImportFailedError, UnexpectedResponseError
├── InvalidRecordIdError, InvalidDurationError
└── InvalidGeometryError, InvalidTableError

The split tells your code what to do next. A ServerError means that the server ran the request and refused it, so the same request fails again. A TransportError means that no answer arrived: the server could not be reached, did not reply in time, or answered with an HTTP error status. The same request can succeed when you send it again. In SDK 2.x, most of these failures raised exceptions from libraries such as requests and aiohttp, so see Migrating to 3.x if your code catches those. The Errors reference lists every class with its properties.

A mistake in the arguments of a call is not a SurrealError. It raises Python's own TypeError or ValueError, as described in Mistakes in a call.

A query passes through two layers, and a failure can come from either one. The first is the request itself: a connection that is unavailable, a rejected sign-in, a query that does not parse. The second is the individual statements inside the query, which can fail while the request as a whole succeeds.

.execute() turns both into an exception. It raises the error of the first statement that failed, so the results of the statements that succeeded are not returned. The other statements still ran, and their changes stay: after CREATE post:one; THROW 'stop'; CREATE post:two, both records exist although .execute() raised. Use a transaction or batch() when statements must succeed or fail together.

Where the outcome of each statement matters, query_raw() returns every statement in place of raising. Each entry carries a status of OK or ERR, and a failed one also carries its kind.

from surrealdb import Surreal, ThrownError

with Surreal("ws://localhost:8000") as db:
    db.signin({"username": "root", "password": "secret"})
    db.use("surrealdb", "docs")

    # .execute() raises the error of the first statement that failed.
    try:
        db.query("RETURN 1; THROW 'second'").execute()
    except ThrownError as error:
        print(f"{error.kind}: {error}")

    # query_raw() reports every statement instead of raising.
    response = db.query_raw("RETURN 1; THROW 'second'")
    for index, statement in enumerate(response["result"]):
        print(index, statement["status"], statement["result"])
Output
Thrown: An error occurred: second
0 OK 1
1 ERR An error occurred: second

query_raw() does not raise when the server refuses the whole request either. A query that does not parse comes back with an error key, holding the kind and message, in place of result.

The server gives every error a kind, and the SDK raises a class of its own for each of the kinds below. Errors describes the meaning of each kind. Match on the class or the kind rather than on the message, which can change between releases.

KindException class
ValidationValidationError
ConfigurationConfigurationError
QueryQueryError
SerializationSerializationError
NotAllowedNotAllowedError
NotFoundNotFoundError
AlreadyExistsAlreadyExistsError
ThrownThrownError
InternalInternalError

A kind without a class of its own, such as Connection or a kind added by a newer server, arrives as the base ServerError with its kind set, so except ServerError catches every server error on every server version.

The examples in the rest of this page use an open connection, db, signed in and using the surrealdb namespace and the docs database. Each class has properties that read the reason out of the error's details. Creating a record that already exists raises AlreadyExistsError, with the id of the record:

from surrealdb import AlreadyExistsError, RecordID

db.upsert(RecordID("person", "tobie"), {"name": "Tobie"})

try:
    db.create(RecordID("person", "tobie"), {"name": "Tobie"})
except AlreadyExistsError as error:
    print(error.record_id)
Output
person:tobie

From SurrealDB 3.0, selecting from a table that does not exist raises NotFoundError, also when you select one record of it. A record that does not exist in a table that does is not an error: select() returns None for it.

from surrealdb import NotFoundError, Table

try:
    db.select(Table("post_archive")).execute()
except NotFoundError as error:
    print(error.table_name)
Output
post_archive

A sign-in with a wrong password raises NotAllowedError. Its properties give the reason, such as is_invalid_auth for wrong credentials and is_token_expired for an expired token:

from surrealdb import NotAllowedError, Surreal

with Surreal("ws://localhost:8000") as db:
    try:
        db.signin({"username": "root", "password": "wrong"})
    except NotAllowedError as error:
        print(error.is_invalid_auth, error)
Output
True There was a problem with authentication

A record user whose SIGNIN clause matches no record gets NotFoundError: No record was returned instead. Catch ServerError around a record sign-in when the reason does not matter to your code. Authentication covers record users.

Every ServerError has a kind string. Compare it with the ErrorKind constants to test for a kind without importing its class. has_kind() is True when the error, or any error in its chain of causes, has the kind, and find_cause() returns the first such error, or None. The cause chain is in server_cause.

from surrealdb import ErrorKind, ServerError, Table

try:
    db.select(Table("post_archive")).execute()
except ServerError as error:
    if error.has_kind(ErrorKind.NOT_FOUND):
        print(error.find_cause(ErrorKind.NOT_FOUND).details)
Output
{'details': {'name': 'post_archive'}, 'kind': 'Table'}

details holds the structured reason that the properties of each class read. It is None when the server sends no reason: for example, a parse error in a query arrives as a ValidationError without details. The detail kind constants name the reasons.

TransportError has three subclasses. Each keeps the exception of the underlying library, such as a ConnectionRefusedError, as __cause__.

ClassRaised when
ConnectionUnavailableErrorThe server cannot be reached, the socket closes while a request waits for its reply, or a live subscription is read after its connection was lost
TransportTimeoutErrorNo reply arrived within the connection's timeout, or within a db.timeout() block
HttpStatusErrorAn HTTP connection got an error status, such as 401

A server that cannot be reached raises ConnectionUnavailableError on the first request, or on connect():

from surrealdb import ConnectionUnavailableError, Surreal

db = Surreal("ws://localhost:8001")

try:
    db.connect()
except ConnectionUnavailableError as error:
    print(error)
Sample output
could not connect to ws://localhost:8001/rpc: [Errno 61] Connection refused

A refused token shows why the two branches are separate. Over a WebSocket, authenticate() with a token the server refuses raises NotAllowedError, because the server answers in its structured error format. Over HTTP, the server answers with status 401 and no structured error, so the same call raises HttpStatusError. In the example below, token holds an access token that the server refuses:

from surrealdb import HttpStatusError, Surreal

with Surreal("http://localhost:8000") as db:
    try:
        db.authenticate(token)
    except HttpStatusError as error:
        print(error.status, error.body)
Output
401 There was a problem with authentication

When a WebSocket connection drops, the SDK opens it again on its own, as described in Automatic reconnection. A request that was in flight when the socket dropped is not sent again, because it may already have run, and raises ConnectionUnavailableError.

A TransportError does not tell you whether the server ran the request. A write that timed out may still have been applied, so retry only statements that give the same result when they run twice, such as an upsert() of a whole record:

import time

from surrealdb import RecordID, TransportError


def upsert_with_retry(db, record, data, attempts=3):
    for attempt in range(attempts):
        try:
            return db.upsert(record, data)
        except TransportError:
            if attempt == attempts - 1:
                raise
            time.sleep(2**attempt)


upsert_with_retry(db, RecordID("person", "tobie"), {"name": "Tobie"})

A ServerError from upsert() passes through at once, because sending the same request again gives the same answer.

Two kinds of timeout raise different classes. TransportTimeoutError means that the SDK stopped waiting for a reply, which can happen while the server still runs the request. A TIMEOUT clause in a statement is enforced by the server, which reports the exceeded limit as a QueryError:

from surrealdb import QueryError

try:
    db.query("SELECT * FROM sleep(500ms) TIMEOUT 50ms").execute()
except QueryError as error:
    print(error.is_timed_out, error.timeout)
Output
True 50ms

An UnsupportedFeatureError means that the type of connection does not have the feature: for example, sessions, transactions and live queries need a WebSocket, so an HTTP connection raises it for them. Its subclass UnavailableFeatureError means that the server version does not have the feature, such as sessions on SurrealDB 2.x. The SDK raises both before it sends anything.

UnavailableFeatureError names the feature, the version of the server and the first version with the feature. Put its except clause before the one for UnsupportedFeatureError, which would otherwise catch it first:

from surrealdb import Surreal, UnavailableFeatureError, UnsupportedFeatureError

with Surreal("ws://localhost:8000") as db:
    db.signin({"username": "root", "password": "secret"})

    try:
        session = db.new_session()
    except UnavailableFeatureError as error:
        print(error.feature, error.version, error.since)
    except UnsupportedFeatureError as error:
        print(error)
Output on SurrealDB 2.7.0
sessions 2.7.0 3.0.0

To check first, call db.is_feature_supported() with a constant such as Features.SESSIONS.

The SDK supports SurrealDB 2.x, 3.x and 4.x. Connecting to a server on any other version raises UnsupportedVersionError before the first request. Pass version_check=False to the connection to connect anyway.

Surreal() and AsyncSurreal() check the URL before they connect. A scheme the SDK does not know raises UnsupportedEngineError, and so does an embedded URL such as mem:// when the surrealdb[embedded] package is not installed. A URL with a known scheme that cannot be parsed, such as one with a port out of range, raises InvalidUrlError:

from surrealdb import InvalidUrlError, Surreal, UnsupportedEngineError

for url in ("ftp://localhost:8000", "ws://localhost:99999"):
    try:
        Surreal(url)
    except UnsupportedEngineError as error:
        print("Unsupported:", error.url)
    except InvalidUrlError as error:
        print("Invalid:", error)
Output
Unsupported: ftp://localhost:8000
Invalid: Could not parse URL 'ws://localhost:99999': Port out of range 0-65535

A statement that fails in import_() does not stop the rest of the import. When the import ends, import_() raises ImportFailedError, with the error of each failed statement in failures. The statements that did not fail were applied. Import and export describes imports. The import below creates person:tobie, which already exists:

from surrealdb import ImportFailedError

try:
    db.import_("OPTION IMPORT; CREATE person:tobie SET name = 'Tobie';")
except ImportFailedError as error:
    print(len(error.failures), type(error.failures[0]).__name__)
Output
1 AlreadyExistsError

A call that the SDK can tell is wrong before it sends anything raises Python's own TypeError or ValueError, not a SurrealError. These are mistakes to fix in the code rather than failures to handle, so except SurrealError lets them through. Examples are a value the SDK cannot encode, arguments of the wrong type, and an empty batch():

from datetime import timedelta

try:
    db.query("RETURN $wait", {"wait": timedelta(seconds=5)}).execute()
except TypeError as error:
    print(error)
Output
cannot encode timedelta for SurrealDB; supported types are the JSON scalars, list, dict, set, bytes, date, time, and the surrealdb data types (RecordID, Table, Datetime, Duration, Range, Geometry, Decimal, UUID, the sized numbers, PlainDate, PlainTime, Timestamp, Interval, BigDecimal, Regex)

Send a Duration in place of a timedelta.

A record user whose table PERMISSIONS deny a statement does not get an error. The server leaves out what the user may not see or change: the statement succeeds with an empty result, query_raw() reports status as OK, and nothing is raised.

# `user` is signed in as a record user who may not update this post
print(user.query("UPDATE post:draft SET published = true").execute())
Output
[[]]

The server does this so that an error cannot reveal what a permission rule hides. An error only for a record that exists would tell the user that the record exists. A denied UPDATE of an existing record and an UPDATE of a record that does not exist therefore give the same result.

An empty result from a write does not tell you whether anything was written. A user who may create a record but not select it gets an empty result for a record that was created. To check the outcome, read the record on a connection that can see it, such as one signed in as root, which table permissions do not limit.

The classes below ServerError come from the kind that the server reports, and SurrealDB 2.x sends no kind. On a 2.x server, every server error is therefore an InternalError:

FailureSurrealDB 3.x and 4.0SurrealDB 2.x
THROW 'nope'ThrownErrorInternalError
A query that does not parseValidationErrorInternalError
Creating a record that existsAlreadyExistsErrorInternalError
signin() with a wrong passwordNotAllowedErrorInternalError
select() of a table that does not existNotFoundErrorReturns []

except ServerError and except SurrealError work on every version. To support SurrealDB 2.x, catch those rather than a subclass such as ThrownError, which a 2.x server never causes.

Was this page helpful?