Client behaviour for Arangox. Arangox uses client implementations to
perform all its connection and execution operations.
To use a library other than :velocy or :mint, implement this behaviour in
a module and pass that module to the :client start option.
One error contract
Every callback that can fail returns %Arangox.Error{} and nothing else — not
a POSIX atom, not a library exception struct, not a bare binary:
{:ok, socket} | {:error, Arangox.Error.t()} # connect/2
{:ok, Response.t(), state} | {:error, Arangox.Error.t(), state} # request/3Arangox.Connection no longer classifies whatever a client happened to
return; it reads :reason off the struct. Populate :reason with the
transport's own atom (:econnrefused, :closed, ...) so applications can
match on it.
The socket-gone signal
A client that detects a lost connection must be able to force a disconnect.
Before, that was the {:error, :noproc, state} sentinel; now it is a
reason class, listed by connection_lost_reasons/0 and tested by
connection_lost?/1. The connection's execute callback maps any error
whose reason is in that set to {:disconnect, ...}, which retires the socket
and reconnects.
Getting this wrong is not a cosmetic error: a dead socket returned to the pool
as an ordinary {:error, ...} stays checked in and fails every later checkout
that draws it.
The set covers the socket genuinely being gone (:closed, :econnreset,
:epipe, ...) and also :timeout/:etimedout, because a request/response
exchange that timed out leaves an unread reply on the socket: the next request
on it would read the previous request's response. :noproc is in the set too,
so a third-party client written against the old sentinel keeps forcing
disconnects.
Credentials
A client is where authentication material is closest to the wire, so it is where redaction has to happen. Two rules:
- never put a header value in an error —
:mintechoes the offending value in{:invalid_header_value, name, value}, which for anauthorizationheader is the credential itself.request/3refuses a header carrying a carriage return, line feed, or null before any client sees it, reporting the name alone; - never put a configured endpoint binary in an error — it may carry
userinfo. Clients receive an already-parsed
Arangox.Endpoint.t/0, which never has any, andArangox.Connectionfills in the (redacted) endpoint on the way out.
Request timeouts
No client may wait on a socket indefinitely. The budget reaches a client as two keys in the per-request options:
:deadline—DBConnection's own option: the absolute monotonic instant, in milliseconds, by which the caller expects to be done. Arangox stamps it when the caller enters the pool, before checkout can spend any of the budget.:request_timeout— a per-request override of the pool's:request_timeout. A ceiling on the derived wait, never a replacement for it.
A client derives its socket wait from those and from the :request_timeout
in Arangox.Connection.t/0, and it derives it again before every
receive — a bound applied per recv call is not a bound on the request,
since a response arriving in N chunks would get N budgets:
deadline = Arangox.Client.deadline(opts, state)
case Arangox.Client.socket_timeout(deadline, opts, state) do
{:ok, timeout} -> transport_recv(socket, timeout)
:elapsed -> {:error, %Arangox.Error{reason: :timeout}, state}
enddeadline/2 falls back to a request-local deadline when the caller
established none, so a client called outside arangox's own request path is
bounded too.
The remaining budget, never a duration. The caller's clock starts when it
asks for a connection, not when one comes free, so a caller that queued out
most of its allowance must get the remainder. Reading opts[:timeout] inside
a callback and using it as a socket wait restarts a clock that has already
been running; see
docs/solutions/architecture-patterns/dbconnection-timeout-is-a-deadline-not-a-duration.md.
A timeout disconnects. Report it with :reason :timeout, which is in
connection_lost_reasons/0. A timed-out HTTP/1.1 connection has unread bytes
and an open request reference on it; returned to the pool, the next checkout
reads the previous response.
Optional callbacks
alive?/1 is optional. A client that does not export it is treated as
alive; see alive?/1.
Summary
Types
Per-request options. The same keyword list Arangox.request/6 and friends
were given, so a client can honour caller-supplied settings, plus the two
keys the timeout budget travels in (see "Request timeouts"). Unknown keys are
ignored.
Callbacks
Whether the socket in the given state is still usable.
Receives an Arangox.Endpoint struct and all the start options from
Arangox.start_link/1.
Receives an Arangox.Request struct, the caller's per-request options and a
connection's state (an Arangox.Connection struct), and returns an
Arangox.Response struct or an Arangox.Error, along with the new state
(which doesn't necessarily need to change).
Functions
Whether the connection in state is still usable.
Closes the connection in state.
Opens a connection through client.
Whether an error means the socket is gone and must not be reused.
Every reason that means the socket cannot be reused, sorted.
The absolute monotonic instant, in milliseconds, this request has to be finished by.
The :request_timeout a pool falls back to, in milliseconds.
Whether client implements the optional alive?/1 callback.
The shortest socket wait worth starting, in milliseconds. A budget that cannot cover it is refused instead of spent.
Runs request through the state's client with the caller's options.
The :request_timeout in force for one request: the per-request override if
the caller gave one, otherwise the pool's.
The socket wait to use for the next receive, derived from the remaining budget.
Milliseconds by which a derived socket wait is kept short of the caller's
deadline, so arangox's own timeout error wins the race against
DBConnection's.
Whether value is usable as a :request_timeout, with the message to report
when it is not.
Types
Per-request options. The same keyword list Arangox.request/6 and friends
were given, so a client can honour caller-supplied settings, plus the two
keys the timeout budget travels in (see "Request timeouts"). Unknown keys are
ignored.
@type socket() :: any()
Callbacks
@callback alive?(state :: Arangox.Connection.t()) :: boolean()
Whether the socket in the given state is still usable.
Optional. A client that does not export it is treated as alive, since the
request path detects a lost socket through the error contract anyway. Do not
call it directly on a :client module; call alive?/1, which checks.
@callback close(state :: Arangox.Connection.t()) :: :ok
@callback connect( endpoint :: Arangox.Endpoint.t(), start_options :: [Arangox.start_option()] ) :: {:ok, socket()} | {:error, Arangox.Error.t()}
Receives an Arangox.Endpoint struct and all the start options from
Arangox.start_link/1.
The socket returned from this callback gets placed in the :socket field of
an Arangox.Connection struct (a connection's state) to be used by the other
callbacks as needed. It can be anything — a tuple, another struct, whatever
the client needs.
It's up to the client to consolidate :connect_timeout, :tcp_opts,
:ssl_opts and :client_opts into whatever its library takes. :tcp_opts
applies to a cleartext endpoint and :ssl_opts to an encrypted one, so a
client reads whichever the endpoint's :ssl? selects.
Must not raise or exit, whatever the transport does with the options it was
given: the connection's connect is a DBConnection callback, and an
exception escaping it costs the process its backoff. Leave :endpoint
on the returned error nil — the caller knows the configured endpoint and
fills in a redacted copy.
@callback request( request :: Arangox.Request.t(), options :: [request_option()], state :: Arangox.Connection.t() ) :: {:ok, Arangox.Response.t(), Arangox.Connection.t()} | {:error, Arangox.Error.t(), Arangox.Connection.t()}
Receives an Arangox.Request struct, the caller's per-request options and a
connection's state (an Arangox.Connection struct), and returns an
Arangox.Response struct or an Arangox.Error, along with the new state
(which doesn't necessarily need to change).
Arangox handles the encoding and decoding of request and response bodies, and merging headers.
If the connection is lost, return an error whose :reason is one of
connection_lost_reasons/0 to force a disconnect. Otherwise an attempt to
reconnect may not be made until the next request hitting this process fails.
Every receive must be bounded. Derive the wait with deadline/2 and
socket_timeout/3; see "Request timeouts" in the module documentation. A
request that was fully written is never retried automatically, so a client
does not have to make its writes idempotent — but it does have to report a
timeout as :timeout so the connection is retired rather than reused.
Signature change in 0.8
This callback took (request, state) before 0.8. The options argument is
now second. A client still exporting request/2 raises when called.
Functions
@spec alive?(Arangox.Connection.t()) :: boolean()
Whether the connection in state is still usable.
Returns true for a client that does not implement alive?/1: the callback
is optional, and a liveness probe that cannot be asked reports optimistically
rather than retiring a working connection. Implement alive?/1 if a client
can decide more cheaply or more accurately than a request would.
The export check runs Code.ensure_loaded?/1 first. function_exported?/3
returns false for a module that merely has not been loaded yet, which
under lazy loading is any module nothing has called into — so the bare check
would report a client as not implementing a callback it does implement, and
the first request against a fresh node would silently take the default.
@spec close(Arangox.Connection.t()) :: :ok
Closes the connection in state.
@spec connect(module(), Arangox.Endpoint.t(), [Arangox.start_option()]) :: {:ok, socket()} | {:error, Arangox.Error.t()}
Opens a connection through client.
@spec connection_lost?(Arangox.Error.t() | atom()) :: boolean()
Whether an error means the socket is gone and must not be reused.
iex> Arangox.Client.connection_lost?(%Arangox.Error{reason: :closed})
true
iex> Arangox.Client.connection_lost?(%Arangox.Error{reason: :arango_conflict})
false
@spec connection_lost_reasons() :: [atom()]
Every reason that means the socket cannot be reused, sorted.
An Arangox.Error carrying one of these forces a disconnect rather than an
ordinary error return. See the module documentation.
@spec deadline([request_option()], Arangox.Connection.t()) :: integer()
The absolute monotonic instant, in milliseconds, this request has to be finished by.
Prefers the caller's :deadline — stamped when the caller entered the pool,
so it already accounts for time spent queueing — and falls back to a
request-local one when there is none, which is what bounds the pool's ping, a
client used directly, and any path that did not enter through Arangox.
@spec default_request_timeout() :: pos_integer()
The :request_timeout a pool falls back to, in milliseconds.
Deliberately the same number as DBConnection's default :timeout, so an
unconfigured pool and an unconfigured caller expire together rather than one
silently masking the other.
Whether client implements the optional alive?/1 callback.
@spec min_socket_timeout() :: pos_integer()
The shortest socket wait worth starting, in milliseconds. A budget that cannot cover it is refused instead of spent.
@spec request(Arangox.Request.t(), [request_option()], Arangox.Connection.t()) :: {:ok, Arangox.Response.t(), Arangox.Connection.t()} | {:error, Arangox.Error.t(), Arangox.Connection.t()} | {:error, term(), Arangox.Connection.t()}
Runs request through the state's client with the caller's options.
Header names and values are checked here first: one carrying a carriage
return, line feed, or null is refused with :invalid_header_value and never
reaches a transport.
The return type is wider than request/3's on purpose: this function
dispatches to a module the user supplied, so what actually comes back is
whatever that module returned. A client that predates the one-error contract
can still return a bare reason, and Arangox.Connection adapts it. The
callback's contract is the strict one — implement that.
@spec request_timeout([request_option()], Arangox.Connection.t()) :: pos_integer()
The :request_timeout in force for one request: the per-request override if
the caller gave one, otherwise the pool's.
An override replaces the pool value rather than being capped by it, so a single slow request can be given room without loosening the pool. It is still only a ceiling on the derived wait — the caller's deadline outranks it.
@spec socket_timeout(integer(), [request_option()], Arangox.Connection.t()) :: {:ok, pos_integer()} | :elapsed
The socket wait to use for the next receive, derived from the remaining budget.
Returns :elapsed when what is left cannot cover min_socket_timeout/0.
Callers before the request is written should report that as an ordinary
error — nothing was sent, so the connection is still good. Callers already
mid-response must report it with a connection-lost reason, because the
response stream has been abandoned part-read.
@spec timeout_margin() :: pos_integer()
Milliseconds by which a derived socket wait is kept short of the caller's
deadline, so arangox's own timeout error wins the race against
DBConnection's.
Whether value is usable as a :request_timeout, with the message to report
when it is not.
Only a positive finite number of milliseconds is admitted. :infinity is
refused as loudly as 0 and negatives: an unbounded request timeout is the
condition the option exists to remove, not a way to configure it.
iex> Arangox.Client.validate_request_timeout(5_000)
:ok
iex> {:error, message} = Arangox.Client.validate_request_timeout(:infinity)
iex> message =~ ":request_timeout"
true