Tutorial: Call another service
View SourceIn this tutorial you build an HTTP client for an upstream service and add what you would want in production to cope with failures: a timeout, retries, a circuit breaker. It is for you once your own service has to call someone else, an API for payments, an internal service for users, a webhook out to a partner. About 15 minutes.
The client works like Livery's middleware, for the requests you send out. If you have written a middleware stack, this will feel familiar: you stack layers around a request the way you stack them around a handler. Same idea, the other direction.
1. The smallest possible call
Start with the least you can write. Build a client, send a GET, read the answer.
Client = livery_client:new(#{base_url => <<"https://api.example.com">>}),
{ok, Resp} = livery_client:get(Client, <<"/health">>),
200 = livery_client:status(Resp),
{full, Body} = livery_client:body(Resp).Three things are happening. new/1 builds a client value, here with
nothing but a base URL, so you can pass paths instead of full URLs.
get/2 sends the request and gives back {ok, Resp} or
{error, Reason}. And the body comes back tagged: {full, Body} is the
whole thing in memory, which is what you want for a small JSON reply.
The client is a value. Build it once, share it, call it from as many processes as you like.
2. Headers and a base URL, set once
Repeating an authorization header on every call is tedious. Put the
defaults on the client and forget about them:
Token = <<"s3cret">>,
Auth = iolist_to_binary([<<"Bearer ">>, Token]),
Client = livery_client:new(#{
base_url => <<"https://api.example.com">>,
headers => [
{<<"authorization">>, Auth},
{<<"accept">>, <<"application/json">>}
]
}),
{ok, _} = livery_client:get(Client, <<"/users/42">>).Every request the client sends now carries those two headers. A per-request header of the same name still wins, so you can override one without rebuilding the client.
3. Turn the response into something useful
A status and some raw bytes are not what your code wants to work with. It wants a decoded value, or a clear error. Send every answer through one decoder:
fetch_user(Client, Id) ->
Path = <<"/users/", Id/binary>>,
case livery_client:get(Client, Path) of
{ok, Resp} -> decode(Resp);
{error, _} = E -> E
end.
decode(Resp) ->
{full, Body} = livery_client:body(Resp),
case livery_client:status(Resp) of
S when S >= 200, S < 300 -> {ok, json:decode(Body)};
404 -> {error, not_found};
S -> {error, {http, S}}
end.Notice you build the path into Path before the call rather than inline.
That is a small habit worth keeping: it reads better, and a single
<<"/users/", Id/binary>> expression in one place is easy to change.
Now fetch_user/2 returns {ok, Map}, {error, not_found}, or
{error, {http, Status}}, and the caller never sees raw HTTP.
4. Add a timeout
The network will, eventually, hang. A call that never returns is worse than one that fails, so put a limit on it. This is your first layer:
Client = livery_client:new(#{
base_url => <<"https://api.example.com">>,
stack => [livery_client:timeout(5000)]
}),
case livery_client:get(Client, <<"/slow">>) of
{ok, Resp} -> livery_client:status(Resp);
{error, timeout} -> too_slow
end.timeout(5000) gives the whole call five seconds; if it takes longer, it
returns {error, timeout} and closes the connection the call was using.
A layer is an entry in the stack list, like a middleware entry.
5. Retry the failures worth retrying
Short-lived failures happen: a 503 while the other side restarts, a
connection reset. For idempotent calls (calls that are safe to repeat),
retrying a few times, waiting a little longer each time, hides most of
them.
Client = livery_client:new(#{
base_url => <<"https://api.example.com">>,
stack => [
livery_client:timeout(5000),
livery_client:retry(#{max => 3, backoff => {200, 2.0}})
]
}),
{ok, _} = livery_client:get(Client, <<"/users/42">>).retry retries transport errors and 502/503/504, up to max times,
waiting 200ms then 400ms then 800ms (base 200, factor 2.0,
plus a small random delay). It only retries idempotent methods unless
you turn on retry_non_idempotent => true, so a POST is left alone by
default.
Order matters here. timeout sits outside retry, so the five seconds
is the budget for all attempts together, not for each one. That is
usually what you want: a firm limit on the whole operation.
6. Stop hammering a service that is down
Retries help a service that has a short problem. They hurt one that is really down: every caller adds more load at the worst moment. A circuit breaker watches how many calls fail. Once too many do, it opens, and calls then fail at once, without touching the network, until the service has had time to recover.
Client = livery_client:new(#{
base_url => <<"https://api.example.com">>,
stack => [
livery_client:timeout(5000),
livery_client:retry(#{max => 3}),
livery_client:circuit_breaker(#{name => api, window => 20, trip => 0.5})
]
}),
case livery_client:get(Client, <<"/users/42">>) of
{ok, Resp} -> livery_client:status(Resp);
{error, circuit_open} -> serve_from_cache();
{error, _} -> give_up()
end.The breaker counts calls in blocks of 20 (window => 20). When a block
ends with half or more of its calls failed (trip => 0.5), the breaker
opens and the next call returns {error, circuit_open} straight away.
Only an error counts as a failure: any response, even a 503, counts as
a success. After a cooldown it half-opens: it lets one test call
through, and if that call succeeds, it closes again. The name is how
breakers are kept apart, so give each upstream its own.
The breaker sits below retry, so it counts each attempt on its own.
While it is open, every attempt gets {error, circuit_open} at once,
without touching the network, but retry still waits between attempts:
the caller sees the error after the last one.
7. Put it together
Here is the whole thing as the module you would actually keep, a small client for one API that returns your own values:
-module(users_api).
-export([client/1, fetch/2]).
client(Token) ->
Auth = iolist_to_binary([<<"Bearer ">>, Token]),
livery_client:new(#{
base_url => <<"https://api.example.com">>,
headers => [{<<"authorization">>, Auth}],
stack => [
livery_client:timeout(5000),
livery_client:retry(#{max => 3, backoff => {200, 2.0}}),
livery_client:circuit_breaker(#{name => users, window => 20, trip => 0.5})
]
}).
fetch(Client, Id) ->
Path = <<"/users/", Id/binary>>,
case livery_client:get(Client, Path) of
{ok, Resp} ->
{full, Body} = livery_client:body(Resp),
case livery_client:status(Resp) of
200 -> {ok, json:decode(Body)};
404 -> {error, not_found};
S -> {error, {http, S}}
end;
{error, _} = E ->
E
end.Call users_api:client/1 once, hold the value, and call fetch/2
wherever you need a user. Timeout, retry, and breaker apply to every
call, and the caller only ever sees {ok, User} or a clear error.
8. Stream a response to your process
A streamed response with stream => true gives you a {stream, Reader}
body and livery_client:read/2, a call that blocks until the next part
of the body arrives. That suits a process whose only job is to read the
body to the end. But a worker that must also react to its own messages,
such as a cancel signal or a progress tick, then needs a second process
to run the read loop.
Push mode works the other way round: the body comes to you. Set
stream_to to a pid and the chunks arrive as messages, so the worker can
receive body chunks and its own control messages side by side:
{ok, Resp} = livery_client:request(Client, get, <<"/blob">>, #{
stream => true,
stream_to => self()
}),
{push, Ref} = livery_client:body(Resp),
download(Ref, 0).
download(Ref, Bytes) ->
receive
{livery_response, Ref, {status, 200, _Headers}} ->
download(Ref, Bytes);
{livery_response, Ref, {chunk, Data}} ->
Got = Bytes + byte_size(Data),
io:format("~p bytes~n", [Got]),
download(Ref, Got);
{livery_response, Ref, done} ->
{ok, Bytes};
{livery_response, Ref, {error, Reason}} ->
{error, Reason};
cancel ->
livery_client:stop_stream(Ref),
{cancelled, Bytes}
end.Ref is opaque and unique to this request, so a worker running several
downloads tells them apart by matching on it. The messages always arrive
in order: one {status, Status, Headers}, then zero or more
{chunk, Binary}, then a single done (or {error, Reason} if the
transfer fails).
The cancel clause is the reason for all this: a plain message in the
same mailbox stops the download halfway. livery_client:stop_stream/1 drops the connection,
so a user who quits a multi-gigabyte fetch stops paying for it immediately.
Backpressure with flow => manual
By default chunks are sent to you as fast as the network delivers them. If the
worker writes each chunk somewhere slower than the network, ask for one
chunk at a time with flow => manual and pull with
livery_client:stream_next/1:
{ok, Resp} = livery_client:request(Client, get, <<"/blob">>, #{
stream => true,
stream_to => self(),
flow => manual
}),
{push, Ref} = livery_client:body(Resp),
receive
{livery_response, Ref, {status, _, _}} -> ok
end,
ok = livery_client:stream_next(Ref), %% pull the first chunk
receive
{livery_response, Ref, {chunk, First}} -> write(First)
end.No chunk arrives until you call stream_next/1, so a slow consumer never
builds an unbounded backlog in its mailbox.
The pull-based {stream, Reader} API stays as it was for simple
consumers; use push mode when one process needs to handle body chunks
in between its own work.
Where to go next
- Need to download something large without buffering it? See Make outbound HTTP requests for streaming responses and request bodies.
- Want to understand why the layers fit together the way they do? Read The middleware pipeline: the client is the same model, outbound.
- Want to use a different HTTP client, or a mock in tests? The transport
is a
livery_client_adapter, the client-side counterpart of the server adapters.