FSIBLOG

How to Make a Simple REST Call in Erlang

How to Make a Simple REST Call in Erlang

Erlang inets application provides the httpc module to make REST calls. The syntax is basic at first glance, but 80% of newbies make the same mistakes: not starting applications, SSL handshake issues on OTP 25+, binary/string confusion and not handling timeouts. This article provides a production-worthy example with all of the above errors explained and resolved.

Overview

Erlang is not the first language that springs to mind when it comes to HTTP clients Python, Node.js and Go are the big three. But if you’re writing distributed systems, message brokers, or telecom systems in Erlang, you will need to make a request to a REST API: either to trigger a webhook, load a config, or connect to a third-party service.

The good news: Erlang comes with an HTTP client called httpc. The bad news: it’s finicky to get setup and the error messages can be very confusing. In this article we’ll show you how to make REST calls in Erlang properly, but more importantly, what to do when things go wrong.

The Basics A Simple GET Request

Here’s the minimal working example that gets passed around in tutorials:

erlang

-module(rest_client).
-export([get_data/0]).

get_data() ->
    inets:start(),
    ssl:start(),
    Url = "https://jsonplaceholder.typicode.com/posts/1",
    case httpc:request(get, {Url, []}, [], []) of
        {ok, {{_Version, 200, _ReasonPhrase}, _Headers, Body}} ->
            io:format("Response Body: ~s~n", [Body]);
        {error, Reason} ->
            io:format("Error: ~p~n", [Reason])
    end.

This works the first time. Run it twice, and it breaks. Use it in production, and it leaks resources. Let’s break down why and fix it properly.

The Error Catalog Every Common Failure, Explain

Error 1: {error, {already_started, inets}}

What you see:

erlang

** exception error: no match of right hand side value {error,{already_started,inets}}

Why it happens: inets:start() is not idempotent. Calling it twice crashes if you pattern-match on ok.

The fix: Use application:ensure_all_started/1 instead. It’s safe to call repeatedly and starts dependencies automatically.

erlang

{ok, _} = application:ensure_all_started(inets),
{ok, _} = application:ensure_all_started(ssl).

Better yet: Start these once at application boot, not inside every request function.

Error 2: {tls_alert, "handshake failure"}

What you see:

erlang

{error,{failed_connect,
    [{to_address,{"api.example.com",443}},
     {inet,[inet],{tls_alert,{handshake_failure,"..."}}}]}}

Why it happens: Starting with OTP 25, httpc no longer silently accepts unverified TLS certificates. If you don’t pass SSL options with proper certificate authorities, the handshake fails.

The fix: Pass explicit SSL options:

erlang

SslOpts = [
    {verify, verify_peer},
    {cacerts, public_key:cacerts_get()},
    {depth, 3},
    {customize_hostname_check,
        [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}
],
HttpOpts = [{ssl, SslOpts}].

public_key:cacerts_get() (available since OTP 25) loads the OS’s trusted root certificates automatically no need to bundle your own CA file.

Error 3: function_clause on Binary URLs

What you see:

erlang

** exception error: no function clause matching
   httpc:request(get, {<<"https://...">>, []}, [], [])

Why it happens: httpc requires URLs as strings (lists of integers), not binaries. If you load your URL from a config file, environment variable, or JSON payload, it’s likely a binary.

The fix: Add a guard clause that converts binaries automatically:

erlang

get_data(Url) when is_binary(Url) ->
    get_data(binary_to_list(Url));
get_data(Url) when is_list(Url) ->
    %% actual logic here
    ...

Error 4: {error, timeout} – The Silent Killer

What you see: Your process hangs forever. No error. No response. Just silence.

Why it happens: Without explicit timeouts, httpc waits indefinitely on a slow or dead server.

The fix: Always set both timeout (full request) and connect_timeout (initial TCP connection):

erlang

HttpOpts = [
    {ssl, SslOpts},
    {timeout, 10000},          % 10s total
    {connect_timeout, 5000}    % 5s for connection
].

Error 5: Garbled UTF-8 in Response Bodies

What you see: Characters like é, ñ, 中文 show up as é or \\303\\251 in your output.

Why it happens: By default, httpc returns the body as a string (list of bytes), which doesn’t preserve multi-byte UTF-8 sequences cleanly when printed with ~s.

The fix: Request the body as a binary:

erlang

Opts = [{body_format, binary}].
httpc:request(get, Request, HttpOpts, Opts).

Then print with ~ts (translated string) instead of ~s. Bonus: binaries are also what JSON libraries like jsx and jsone expect.

Error 6: nxdomain and econnrefused

What you see:

erlang

{error,{failed_connect,[{to_address,{"api.foo.com",443}},{inet,[inet],nxdomain}]}}
{error,{failed_connect,[{to_address,{"localhost",8080}},{inet,[inet],econnrefused}]}}

Why it happens:

The fix: These are environment problems, not code bugs. Handle them gracefully:

erlang

{error, {failed_connect, _}} = Err ->
    log_network_failure(Err),
    {error, network_unreachable}

Error 7: Forgetting to Handle Non-200 Status Codes

What you see: Your code “succeeds” but your data is empty or wrong. Pattern matching on 200 only handles the happy path; 404, 500, 401 fall through silently.

The fix: Match on status code explicitly:

erlang

case httpc:request(...) of
    {ok, {{_, 200, _}, _, Body}}        -> {ok, Body};
    {ok, {{_, 404, _}, _, _}}            -> {error, not_found};
    {ok, {{_, 401, _}, _, _}}            -> {error, unauthorized};
    {ok, {{_, S, _}, _, _}} when S >= 500 -> {error, {server_error, S}};
    {ok, {{_, S, _}, _, _}}              -> {error, {http_status, S}};
    {error, Reason}                      -> {error, Reason}
end.

The Production-Ready Implementation

Putting all the fixes together:

erlang

-module(rest_client).
-export([start_deps/0, get_data/0, get_data/1]).

%% Call this once at application boot not per request
start_deps() ->
    {ok, _} = application:ensure_all_started(inets),
    {ok, _} = application:ensure_all_started(ssl),
    ok.

get_data() ->
    get_data("https://jsonplaceholder.typicode.com/posts/1").

get_data(Url) when is_binary(Url) ->
    get_data(binary_to_list(Url));
get_data(Url) when is_list(Url) ->
    SslOpts = [
        {verify, verify_peer},
        {cacerts, public_key:cacerts_get()},
        {depth, 3},
        {customize_hostname_check,
            [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}
    ],
    HttpOpts = [
        {ssl, SslOpts},
        {timeout, 10000},
        {connect_timeout, 5000}
    ],
    Opts = [{body_format, binary}],
    Headers = [
        {"User-Agent", "erlang-httpc/1.0"},
        {"Accept", "application/json"}
    ],
    Request = {Url, Headers},

    case httpc:request(get, Request, HttpOpts, Opts) of
        {ok, {{_V, 200, _R}, _Hdrs, Body}} ->
            {ok, Body};
        {ok, {{_V, 404, _R}, _Hdrs, _Body}} ->
            {error, not_found};
        {ok, {{_V, 401, _R}, _Hdrs, _Body}} ->
            {error, unauthorized};
        {ok, {{_V, Status, _R}, _Hdrs, _Body}} when Status >= 500 ->
            {error, {server_error, Status}};
        {ok, {{_V, Status, _R}, _Hdrs, _Body}} ->
            {error, {http_status, Status}};
        {error, {failed_connect, _}} = Err ->
            Err;
        {error, timeout} ->
            {error, timeout};
        {error, Reason} ->
            {error, Reason}
    end.

Testing It

Fire up the Erlang shell:

erlang

1> c(rest_client).
{ok,rest_client}
2> rest_client:start_deps().
ok
3> rest_client:get_data().
{ok,<<"{\n  \"userId\": 1,\n  \"id\": 1,\n  \"title\": \"sunt aut facere...\",\n  \"body\": \"...\"\n}">>}

Parsing JSON Responses

httpc gives you raw bytes. To work with JSON, add jsx (or jsone) to your rebar.config:

erlang

{deps, [{jsx, "3.1.0"}]}.

Then decode the body:

erlang

{ok, Body} = rest_client:get_data(),
Decoded = jsx:decode(Body, [return_maps]).
%% => #{<<"userId">> => 1, <<"id">> => 1, <<"title">> => <<"sunt aut...">>, ...}

POST, PUT, DELETE – The Quick Reference

erlang

%% POST with JSON body
Body = jsx:encode(#{<<"name">> => <<"Alice">>, <<"age">> => 30}),
httpc:request(post,
    {Url, Headers, "application/json", Body},
    HttpOpts, Opts).

%% PUT
httpc:request(put,
    {Url, Headers, "application/json", Body},
    HttpOpts, Opts).

%% DELETE
httpc:request(delete, {Url, Headers}, HttpOpts, Opts).

When to Reach for Something Else

httpc is fine for:

Switch to hackney or gun when you need:

Exit mobile version