Remote calls
howdy_remote is an optional package for typed calls from one Howdy service
to another. You define a procedure once, serve it from the service that
owns the data, and call it from anywhere. The calls travel over Erlang
distribution between your own nodes, or over HTTP across a trust boundary,
and the call site is the same for both.
howdy_remote = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2.1.0", path = "remote" }
Procedures
A procedure is a name, a codec for its input and a codec for its output:
import gleam/dynamic/decode
import gleam/json
import howdy/remote
pub type User {
User(id: Int, name: String)
}
pub fn get_user() -> remote.Procedure(Int, User) {
remote.procedure("users.get", input: remote.int(), output: user())
}
pub fn list_users() -> remote.Procedure(Nil, List(User)) {
remote.procedure("users.list", input: remote.nil(), output: remote.list(user()))
}
pub fn user_to_json(user: User) -> json.Json {
json.object([#("id", json.int(user.id)), #("name", json.string(user.name))])
}
fn user() -> remote.Codec(User) {
remote.codec(encode: user_to_json, decoder: {
use id <- decode.field("id", decode.int)
use name <- decode.field("name", decode.string)
decode.success(User(id:, name:))
})
}
Put these in a module both services import. A small package that both depend on works best: the caller and the server then always agree on names and types.
remote.int, float, string, bool and nil cover the simple cases,
and remote.list(of:) and remote.optional(of:) build on them. For your
own types, remote.codec pairs a JSON encoder with a decoder.
The name is all that crosses the wire, so it must be unique among the
procedures a server handles. Prefix it with the service, as in
"users.get".
Inputs and outputs always travel as JSON, and are decoded when they arrive, even between Erlang nodes. Two services deployed at different times can disagree about a type; decoding turns that into an error at the boundary, not a value of the wrong shape that crashes something later.
Serving
A handler takes the input and returns a
service.Result, the same type controllers
use, so one service function can back both an HTTP route and a procedure:
import howdy/remote
import howdy/service
pub fn find(id: Int) -> service.Result(User) {
list.find(users, fn(user) { user.id == id })
|> result.replace_error(service.NotFound("user not found"))
}
pub fn server() -> remote.Server {
remote.server()
|> remote.handle(users_api.get_user(), find)
|> remote.handle(users_api.list_users(), fn(_) { Ok(users) })
}
Then offer it to the other nodes of the cluster, over HTTP, or both:
// To every connected node, over Erlang distribution:
let assert Ok(_) = remote.start(server())
// Over HTTP, as one POST /rpc/<name> route per procedure:
howdy.new()
|> howdy.controller(remote.controller(server(), at: "/rpc", token: secret))
remote.start links the server to the process that calls it;
remote.supervised(server) gives a child specification for a supervisor
instead. Each call runs in a process of its own on the serving node, so a
slow or crashing handler only affects its own call. Several nodes can serve
the same procedure, and share the calls between them.
When a handler returns service.Internal(detail), the detail is logged on
the serving node and never sent to the caller.
Calling
remote.call(target, users_api.get_user(), 42, timeout: 5000)
The target decides how the call travels. Nothing else at the call site changes:
-
remote.cluster(): any connected node that serves the procedure. This node goes first if it serves it; otherwise each call picks a node at random. -
remote.node("users@10.0.0.5"): one node, connected on first use. -
remote.http("https://users.internal/rpc", token: secret): a server mounted withremote.controller.
The result is the output, or a remote.Error saying why there is none:
case remote.call(users, users_api.get_user(), id, timeout: 5000) {
Ok(user) -> todo
Error(remote.Failed(service.NotFound(_))) -> todo // the handler's own error
Error(remote.NoHandler(_)) -> todo // nothing serves it
Error(remote.Unavailable(_)) -> todo // node down, HTTP failed
Error(remote.Timeout) -> todo // it may still have run
Error(remote.Crashed(_)) -> todo // the handler crashed
Error(remote.BadResponse(_)) -> todo // the output did not decode
Error(remote.Refused) -> todo // HTTP token rejected
Error(_) -> todo
}
A Timeout does not mean the handler did nothing. Make procedures that
change data safe to call twice, or check before retrying.
From a controller
remote.respond answers a request like service.respond. The handler's own
errors pass through, so a NotFound on the users service is still a 404
here. Every transport failure becomes a logged 500:
pub fn app(users: remote.Target) -> howdy.App {
howdy.new()
|> howdy.controller(
controller.new("/users")
|> controller.get("/:id", fn(ctx) {
use id <- param.int(ctx, "id")
remote.call(users, users_api.get_user(), id, timeout: 5000)
|> remote.respond(ctx, users_api.user_to_json)
}),
)
}
Taking the target as an argument, as here, lets the same app call over distribution in one deployment and over HTTP in another.
More ways to call
-
remote.cast(target, procedure, input)calls without waiting for, or learning about, the outcome. -
remote.multicall(procedure, input, timeout:)calls every node that serves a procedure, in parallel, and returns each node's name and result. Use it to clear caches everywhere, or collect statistics. -
remote.providers(procedure)lists the nodes serving a procedure, for health checks.
To run the services as a cluster, and to choose between distribution and HTTP, see Clusters and security.
Testing
Both halves are plain Gleam, so they can be tested in one VM. Start the
server in the test, and call it with remote.cluster(), which includes the
node the test runs on:
pub fn get_user_test() {
let assert Ok(_) = remote.start(users.server())
let assert Ok(user) =
remote.call(remote.cluster(), users_api.get_user(), 1, timeout: 1000)
assert user.name == "Ada"
}
To test a controller that calls out, pass it a target and send it requests
with howdy/testing.
