TypeDB.Error exception (TypeDB v0.9.0)

Copy Markdown View Source

The single exception type raised (or returned) by every TypeDB operation.

Errors carry a :kind describing where the failure came from:

  • :server — TypeDB answered with a structured error body. :code holds the TypeDB error code (e.g. "TSV11", "AUT3", "HSR4"), :status the HTTP status, and :message the server's message, including its cause trace.
  • :transport — the request never produced an HTTP response (connection refused, DNS failure, TLS failure, socket closed).
  • :timeout — the request exceeded the configured timeout.
  • :unauthenticated — credentials were rejected, or the token expired and could not be renewed.
  • :decode — the response body was not valid JSON, or did not match the shape this driver expects.
  • :encode — the mirror of :decode: an Elixir term could not be turned into a TypeDB wire value. A given_rows entry of a type TypeDB has no equivalent for, or a TypeDB.Duration with a negative component, which TypeQL's grammar cannot express. Raised rather than returned, because it happens while the request is still being built and there is nothing to fail.
  • :config — the driver was configured incorrectly. Raised at start-up.

Matching on TypeDB error codes

case TypeDB.query(conn, "social", "match $x isa nonexistent;") do
  {:error, %TypeDB.Error{kind: :server, code: code}} -> {:bad_query, code}
  {:ok, answer} -> answer
end

Error codes are stable across TypeDB releases and are the recommended thing to branch on; messages are not.

Summary

Functions

Builds an error from a TypeDB error response body.

Whether retrying the call that produced this error could plausibly help.

TypeDB error codes that retryable?/1 treats as worth another attempt, whatever status they arrive with.

The response statuses the driver treats as retryable by default.

Types

kind()

@type kind() ::
  :server
  | :transport
  | :timeout
  | :unauthenticated
  | :decode
  | :encode
  | :config

t()

@type t() :: %TypeDB.Error{
  __exception__: term(),
  body: term(),
  code: String.t() | nil,
  kind: kind(),
  message: String.t(),
  reason: term(),
  status: pos_integer() | nil
}

Functions

from_response(status, body)

@spec from_response(pos_integer(), term()) :: t()

Builds an error from a TypeDB error response body.

TypeDB error bodies are %{"code" => code, "message" => message}. Anything else is reported verbatim so that no information is lost.

new(kind, message, opts \\ [])

@spec new(kind(), String.t(), keyword()) :: t()

Builds an error.

Public because TypeDB.HTTP is a public extension point and TypeDB.HTTP.request/6 is required to return one of these. Application code should be matching on errors, not constructing them.

TypeDB.Error.new(:transport, "connection refused", reason: :econnrefused)

Options

  • :code — TypeDB's own error code, for :server errors
  • :status — the HTTP status
  • :reason — the underlying term, whatever it was
  • :body — the response body that could not be understood

retryable?(error)

@spec retryable?(t()) :: boolean()

Whether retrying the call that produced this error could plausibly help.

case TypeDB.transaction(conn, "social", :write, &steps/1) do
  {:error, %TypeDB.Error{} = error} ->
    if TypeDB.Error.retryable?(error), do: retry_the_whole_thing(), else: give_up(error)

  result ->
    result
end

Note what this is not for. By the time you are holding an error the driver has already retried whatever its configuration allowed, so a true here does not mean it gave up early. This is for the layer above — retrying a whole transaction, requeueing a job — where the unit of work is bigger than one HTTP call and the driver could not have retried it for you.

:server errors are judged by retryable_statuses/0 rather than by a connection's :retry_on_status, because an error does not carry the connection that produced it — plus retryable_codes/0, which is how an isolation conflict qualifies despite arriving as a 400 and a vanished transaction despite arriving as a 404. The first is the reason this function exists: a commit rejected because a concurrent :write transaction won the race is exactly the failure a caller is meant to replay.

iex> TypeDB.Error.retryable?(TypeDB.Error.new(:transport, "connection refused"))
true

iex> conflict = TypeDB.Error.new(:server, "isolation conflict", code: "STC2", status: 400)
iex> TypeDB.Error.retryable?(conflict)
true

iex> gone = TypeDB.Error.new(:server, "no open transaction", code: "TSV12", status: 404)
iex> TypeDB.Error.retryable?(gone)
true

iex> TypeDB.Error.retryable?(TypeDB.Error.new(:server, "no such database", status: 404))
false

iex> TypeDB.Error.retryable?(TypeDB.Error.new(:unauthenticated, "bad password"))
false

retryable_codes()

@spec retryable_codes() :: [String.t()]

TypeDB error codes that retryable?/1 treats as worth another attempt, whatever status they arrive with.

STC2 is an isolation conflict: two concurrent :write transactions touched the same data and the loser's commit was rejected. It arrives as a 400, which is otherwise the driver's signal that a request will fail the same way forever — and this one will not. Replaying the transaction against the committed state is the intended response, and the only one available: the conflict invalidates the whole transaction, so the driver cannot retry it for you.

TSV12 is "no open transaction", a 404. The transaction the request named is gone, and nothing it wrote was committed, so replaying it is again both the right answer and the only one. It arrives when the transaction outlived its transaction_timeout_millis, when a :timeout or :transport failure made the driver hang up and TypeDB discarded the transaction with the client, or when the server restarted underneath it — every one of which is an operational outcome rather than a statement about the work.

The honest caveat: TSV12 also answers a request made on a transaction the caller already finished. That is a bug rather than a race, and calling it retryable means the caller replays, fails the same way, and learns it from the second failure instead of the first. The trade is deliberate — the cost of being wrong the other way is a transaction that merely took too long being reported as permanent, and its work thrown away — and it does not reach TypeDB.transaction/5, which has no window in which to use a spent handle. The one case where true is genuinely wrong is a request issued after a successful commit: there the work did land, and replaying applies it twice.

Note that this is not the same list as :retry_on_status. The driver never retries a request on one of these codes by itself, because the unit that has to be retried is bigger than the request.

retryable_statuses()

@spec retryable_statuses() :: [pos_integer()]

The response statuses the driver treats as retryable by default.

429 is a server shedding load; 502, 503 and 504 are what a proxy, an ingress or a load balancer answers while TypeDB restarts. All four say "not now" rather than "no". This is the default of :retry_on_status.