katipo

build status Hex pm Hex Docs

An HTTP/1.1, HTTP/2 and HTTP/3 client for Erlang, built on libcurl's multi interface and libevent: libcurl's protocol support, many concurrent connections per worker.

Install

{deps, [{katipo, "2.0.1"}]}.

Building needs a C compiler, make, and the libcurl and libevent headers:

apt-get install gcc make libcurl4-openssl-dev libevent-dev   # Debian/Ubuntu
brew install curl libevent pkg-config                        # macOS

On macOS the build prefers Homebrew's curl, which has HTTP/3.

Quick start

{ok, _} = application:ensure_all_started(katipo),
{ok, _} = katipo_pool:start(api, 2, [{pipelining, multiplex}]),

{ok, #{status := 200, body := Body}} = katipo:get(api, <<"https://example.com">>),

{ok, #{status := 200}} =
    katipo:post(api, <<"https://httpbin.org/post">>,
                #{headers => [{<<"content-type">>, <<"application/json">>}],
                  body => <<"{\"name\":\"katipo\"}">>,
                  timeout_ms => 5000}).

Every method has a function (get, post, put, head, options, patch, delete) taking a pool, a URL and an optional options map. katipo:req(Pool, #{url => Url, method => post, ...}) takes the whole request as one map.

Responses

{ok, #{status := pos_integer(), headers := [{binary(), binary()}],
       cookiejar := cookiejar(), body := binary()}}
{error, #{code := atom(), message := binary()}}

code is a curl error name (couldnt_connect, operation_timedout, ...) or one of katipo's: bad_opts, worker_died, overload, admission_timeout, await_timeout.

Async requests

{ok, Ref} = katipo:async_get(api, Url),
{ok, #{status := 200}} = katipo:await(Ref, 5000).

The result arrives as one message, {katipo_response, Ref, Response} or {katipo_error, Ref, Error}, sent to the caller or to the reply_to pid. await/1,2 receives it for you; await_timeout only ends the wait, so a later await can still collect it. cancel(Pool, Ref) aborts the request; a message already sent before the cancel took effect may still be in the mailbox.

An admitted request always gets exactly one message, including worker_died if its worker's C port dies. If no worker can take the request, async_* returns the error itself (worker_died, overload or admission_timeout) and sends nothing. The one gap: a worker killed with exit(Pid, kill) sends nothing, so keep a timeout on the wait.

Streaming

stream => true delivers the body as it arrives instead of buffering it. stream_window => N pauses the transfer after N unacknowledged chunks, which pushes back on the server, until update_flow/3 grants more:

download(Url) ->
    {ok, Ref} = katipo:async_get(api, Url, #{stream => true, stream_window => 8}),
    receive {katipo_headers, Ref, #{status := 200}} -> ok end,
    loop(Ref, []).

loop(Ref, Acc) ->
    receive
        {katipo_chunk, Ref, Bin} ->
            ok = katipo:update_flow(api, Ref, 1),
            loop(Ref, [Acc | Bin]);
        {katipo_done, Ref, _} -> {ok, iolist_to_binary(Acc)};
        {katipo_error, Ref, Error} -> {error, Error}
    end.

A katipo_error can arrive at any point, including mid-body. Grant at least once every N chunks, or the transfer stays paused until timeout_ms fires. Streaming is async-only and await does not apply. With HTTP/2 or HTTP/3 multiplexing, use a recent libcurl: older versions can stall other streams on the connection while one is paused.

Pools

katipo_pool:start(Name, Size, Opts) starts Size workers, each owning one curl multi handle. katipo_pool:stop(Name) stops them.

Option Type Default Note
pipelining nothing
http1
multiplex
nothing HTTP pipelining CURLMOPT_PIPELINING
max_total_connections non_neg_integer() 0 (no limit) docs
max_concurrent_streams non_neg_integer() 100 docs curl >= 7.67.0
max_in_flight pos_integer() | infinity infinity katipo's own: per-worker cap on in-flight requests

With max_in_flight, a request that finds its worker full spills over to another, and fails fast with overload when every worker is full.

Request options

Option Type Default Notes
headers [{binary(), iodata()}] []
cookiejar opaque (returned in response) []
body iodata() <<>>
connecttimeout_ms pos_integer() 30000 docs
followlocation boolean() false docs
ssl_verifyhost boolean() true docs
ssl_verifypeer boolean() true docs
capath binary() undefined docs
cacert binary() undefined docs
ca_cache_timeout integer() 86400 docs curl >= 7.87.0 (0=disable, -1=forever)
timeout_ms pos_integer() 30000 docs
dns_cache_timeout integer() 60 docs (0=disable, -1=forever)
maxredirs non_neg_integer() 9 docs
proxy binary() undefined docs
tcp_fastopen boolean() false docs curl >= 7.49.0
pipewait boolean() true docs curl >= 7.43.0
interface binary() undefined docs
unix_socket_path binary() undefined docs curl >= 7.40.0
doh_url binary() undefined docs curl >= 7.62.0
http_version curl_http_version_none
curl_http_version_1_0
curl_http_version_1_1
curl_http_version_2_0
curl_http_version_2tls
curl_http_version_2_prior_knowledge
curl_http_version_3
curl_http_version_none docs HTTP/3 requires curl >= 7.66.0
sslversion sslversion_default
sslversion_tlsv1
sslversion_tlsv1_0
sslversion_tlsv1_1
sslversion_tlsv1_2
sslversion_tlsv1_3
sslversion_default docs
sslcert binary() undefined docs
sslkey binary() undefined docs
sslkey_blob binary() (DER format) undefined docs curl >= 7.71.0
keypasswd binary() undefined docs
http_auth basic
digest
ntlm
negotiate
undefined docs
stream boolean() false async only; deliver the body incrementally (see Streaming responses)
stream_window pos_integer() | infinity infinity chunk credits before the transfer pauses awaiting update_flow/3
username binary() undefined docs
password binary() undefined docs
userpwd binary() undefined docs
verbose boolean() false docs
reply_to pid() self() Async only (async_*): process that receives the response message. Accepted but ignored on synchronous requests.

Some options depend on how libcurl was built. Check at runtime with katipo:http3_available/0, doh_url_available/0, tcp_fastopen_available/0, unix_socket_path_available/0 and sslkey_blob_available/0.

Observability

katipo emits OpenTelemetry spans and metrics. Without an SDK they are no-ops. Each request gets a span:

Attribute Description
http.request.method HTTP method (GET, POST, etc.)
url.full Request URL (query string, fragment and userinfo are stripped for security)
server.address Target host
http.response.status_code Response status code (on success)

Metrics (histograms are in milliseconds and carry http.request.method):

Metric Type Description
http.client.requests Counter Number of HTTP requests (with result and http.response.status_code attributes)
http.client.duration Histogram Total request duration (ms)
http.client.curl_time Histogram Curl total time (ms)
http.client.namelookup_time Histogram DNS lookup time (ms)
http.client.connect_time Histogram Connection time (ms)
http.client.appconnect_time Histogram SSL/TLS handshake time (ms)
http.client.pretransfer_time Histogram Pre-transfer time (ms)
http.client.redirect_time Histogram Redirect processing time (ms)
http.client.starttransfer_time Histogram Time to first byte (ms)

To export them, add the SDK and an exporter:

%% In rebar.config
{deps, [
    {opentelemetry, "1.5.0"},
    {opentelemetry_experimental, "0.5.1"},
    {opentelemetry_exporter, "1.8.0"}
]}.

Configure the exporter in your sys.config:

[
 {opentelemetry, [
   {span_processor, batch},
   {traces_exporter, otlp}
 ]},
 {opentelemetry_experimental, [
   {readers, [
     #{module => otel_metric_reader,
       config => #{exporter => {opentelemetry_exporter, #{}}}}
   ]}
 ]},
 {opentelemetry_exporter, [
   {otlp_endpoint, "http://localhost:4318"}
 ]}
].

Upgrading from 1.x

Metrics moved to OpenTelemetry: mod_metrics, the return_metrics option and the metrics field in responses are gone, and the histograms above carry the same timings. Everything else is additive; see the CHANGELOG.

Development

The tests run against httpbin behind Caddy (HTTPS, HTTP/2 and HTTP/3 on port 8443):

(cd test/http3-httpbin && docker compose up -d)
curl -k https://localhost:8443/get   # wait for a 200; a 502 means httpbin is down
rebar3 ct

The async admission and streaming flow-control protocols are modelled in TLA+ and proved in Lean under formal/, and formal/trace.sh checks the test runs against the Lean model.