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. Internal never carries the remote detail, which is logged on the serving node instead. An input that the server could not decode arrives as Invalid.

  • 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.

  • Timeout

    No 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.

  • Refused

    The 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

Where a call goes.

pub opaque type Target

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 describe(error: Error) -> String

A one-line description of an error, for logs.

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 name(procedure: Procedure(input, output)) -> String
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 server() -> Server
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.

✨ Search Document