howdy/remote
Typed calls from one Howdy service to another, over Erlang distribution or HTTP.
Define each procedure once, in a module both services can import:
pub fn get_user() -> remote.Procedure(Int, User) {
remote.procedure("users.get", input: codec.int(), output: user_codec())
}
Serve it from the service that owns the data. A handler returns the same
service.Result a controller works with:
remote.server()
|> remote.handle(users_api.get_user(), user_service.find)
|> remote.start
Call it from anywhere:
case remote.call(remote.cluster(), users_api.get_user(), 42, timeout: 5000) {
Ok(user) -> ...
Error(remote.Failed(service.NotFound(_))) -> ...
Error(error) -> ...
}
Inputs and outputs are encoded as JSON and decoded at the boundary, even
between Erlang nodes. Two services built at different times can disagree
on a type; decoding turns that into a BadResponse or an Invalid
error instead of an ill-typed value that crashes somewhere else later.
It also means one procedure works over both transports unchanged.
Erlang distribution gives every connected node full control of every
other: a node holding the cookie can run any code on yours, whatever
this module allows. Use cluster and node only between nodes on a
private network. Across a trust boundary, serve with controller and
call with http.
Types
How to turn a value into JSON and back. The same type as
howdy/codec.Codec, which has the constructors.
pub type Codec(a) =
codec.Codec(a)
Why a call did not return a value.
pub type Error {
Failed(service.Error)
NoHandler(procedure: String)
Unavailable(String)
Timeout
Crashed(String)
BadResponse(String)
Refused
}
Constructors
-
Failed(service.Error)The handler ran and returned this error.
Internalnever carries the remote detail, which is logged on the serving node instead. An input that the server could not decode arrives asInvalid. -
NoHandler(procedure: String)Nothing at the target handles the named procedure.
-
Unavailable(String)The target could not be reached: the node is down or not connected, or the HTTP request failed.
-
TimeoutNo reply arrived in time. The handler may still have run.
-
Crashed(String)The handler raised an exception or exited. Over HTTP the detail stays in the serving node’s log.
-
BadResponse(String)The reply could not be decoded, usually because the two services disagree on the procedure’s output type.
-
RefusedThe HTTP endpoint refused the token.
A named function one service offers another, taking an input and
returning an output. The name is all that crosses the wire, so it must
be unique among the procedures a server handles. A dotted name such as
"users.get" keeps services apart.
pub opaque type Procedure(input, output)
The procedures one service offers. Build it with handle, then start
it for Erlang distribution, mount it with controller for HTTP, or both.
pub opaque type Server
Values
pub fn apply(
on node: String,
module module: String,
function function: String,
args args: List(dynamic.Dynamic),
decoder decoder: decode.Decoder(a),
timeout timeout: Int,
) -> Result(a, Error)
Call any exported function on a node, for services that do not use
howdy_remote. module is the Erlang module name: "users@api" for the
Gleam module users/api, "Elixir.Billing" for Elixir’s Billing.
Nothing checks the arguments, so build them with gleam/dynamic; the
return value is checked with decoder.
remote.apply(
on: "billing@10.0.0.7",
module: "Elixir.Billing",
function: "balance",
args: [dynamic.int(account_id)],
decoder: decode.int,
timeout: 5000,
)
pub fn bool() -> codec.Codec(Bool)
The same as codec.bool in howdy/codec, kept so code written before
that module existed still compiles.
pub fn call(
target: Target,
procedure: Procedure(input, output),
input: input,
timeout timeout: Int,
) -> Result(output, Error)
Call a procedure and wait up to timeout milliseconds for its output.
pub fn cast(
target: Target,
procedure: Procedure(input, output),
input: input,
) -> Nil
Call a procedure without waiting for, or learning about, the outcome. Over distribution nothing waits at all; over HTTP the request is made from a new process with a 30 second timeout. At most 64 casts are in flight at once; beyond that they are dropped with a warning.
pub fn cluster() -> Target
Any connected node that serves the procedure, preferring this one. With several nodes serving it, each call picks one at random. Nodes join and leave automatically as they start servers, stop or disconnect.
pub fn codec(
encode encode: fn(a) -> json.Json,
decoder decoder: decode.Decoder(a),
) -> codec.Codec(a)
The same as codec.new in howdy/codec, kept so code written before
that module existed still compiles.
pub fn connect(node: String) -> Result(Nil, Error)
Connect to a node now, rather than on the first call. Connecting to one
node of a cluster connects to the rest. A name that is not name@host
is Unavailable.
pub fn connect_to(nodes: List(String)) -> Result(Nil, Error)
Keep this node connected to nodes. Erlang connects to a node once and
does not try again when the connection drops, so a service that depends
on another node needs something that does. A connector under the
howdy_remote application’s supervisor connects to each node, retries
a lost one with a backoff that doubles from a second to half a minute,
and logs each connection and loss once. Calling it again adds nodes.
let assert Ok(Nil) = remote.connect_to(["users@10.0.0.5"])
The call returns as soon as the connector is running, not once the nodes
are connected. A name that is not name@host, or a node that is not
distributed, is Unavailable. Apps that run their own supervision tree
can use connector_supervised instead.
pub fn connector_supervised(
nodes: List(String),
) -> supervision.ChildSpecification(Nil)
connect_to as a child of the application’s own supervisor:
static_supervisor.new(static_supervisor.OneForOne)
|> static_supervisor.add(remote.connector_supervised(["users@10.0.0.5"]))
|> static_supervisor.add(howdy.supervised(app))
pub fn controller(
server: Server,
at prefix: String,
token token: String,
) -> controller.Builder(Nil)
Serve the procedures over HTTP, one POST <at>/<procedure name> route
each. Callers must send authorization: Bearer <token>; use a long
random secret and TLS. An empty token refuses every request.
howdy.new()
|> howdy.controller(remote.controller(server, at: "/rpc", token: secret))
pub fn float() -> codec.Codec(Float)
The same as codec.float in howdy/codec, kept so code written before
that module existed still compiles.
pub fn handle(
server: Server,
procedure: Procedure(input, output),
handler: fn(input) -> Result(output, service.Error),
) -> Server
Serve procedure with handler. Handling a name twice keeps the last.
pub fn http(url: String, token token: String) -> Target
A server mounted with controller, at the URL of its prefix such as
"https://users.internal/rpc".
pub fn int() -> codec.Codec(Int)
The same as codec.int in howdy/codec, kept so code written before
that module existed still compiles.
pub fn list(of item: codec.Codec(a)) -> codec.Codec(List(a))
The same as codec.list in howdy/codec, kept so code written before
that module existed still compiles.
pub fn multicall(
procedure: Procedure(input, output),
input: input,
timeout timeout: Int,
) -> List(#(String, Result(output, Error)))
Call a procedure on every node in the cluster that serves it, in
parallel, and wait up to timeout milliseconds for them all. Returns
each node’s name and result, sorted by node name. Useful for clearing
caches or collecting statistics.
pub fn nil() -> codec.Codec(Nil)
The same as codec.nil in howdy/codec, kept so code written before
that module existed still compiles.
pub fn node(name: String) -> Target
The node with this full name, such as "users@10.0.0.5". The node is
connected on first use if the cookies match.
pub fn optional(
of item: codec.Codec(a),
) -> codec.Codec(option.Option(a))
The same as codec.optional in howdy/codec, kept so code written before
that module existed still compiles.
pub fn procedure(
name: String,
input input: codec.Codec(input),
output output: codec.Codec(output),
) -> Procedure(input, output)
pub fn procedures(server: Server) -> List(String)
The names of the procedures a server handles, sorted.
pub fn providers(
procedure: Procedure(input, output),
) -> List(String)
The nodes in the cluster that serve a procedure, sorted. Useful for health checks.
pub fn respond(
result: Result(a, Error),
ctx: context.Context(guarded),
encode: fn(a) -> json.Json,
) -> response.Response(content.Content)
Answer a request with the result of a call, like service.respond.
remote.call(users, users_api.get_user(), id, timeout: 5000)
|> remote.respond(ctx, user.to_json)
pub fn self() -> String
The full name of this node, such as "users@10.0.0.5", or
"nonode@nohost" when distribution is off.
pub fn start(
server: Server,
) -> Result(actor.Started(Nil), actor.StartError)
Offer the server’s procedures to every node in the cluster, including this one. The server is linked to the calling process; each call runs in its own process on this node, so the server never blocks on a handler.
Start one server per node for each set of procedures. Several nodes
serving the same procedure share the calls made through cluster.
pub fn string() -> codec.Codec(String)
The same as codec.string in howdy/codec, kept so code written before
that module existed still compiles.
pub fn supervised(
server: Server,
) -> supervision.ChildSpecification(Nil)
start as a child of a supervisor.
pub fn to_service_error(error: Error) -> service.Error
The service error a controller should answer with. The remote service’s
own errors pass through unchanged so a NotFound stays a 404; every
transport failure becomes Internal and is logged.