Fetch
A small HTTP/1.1 client written in plain Elixir/OTP.
Why it exists
I've always wanted to write my own HTTP client, so here it is. This is purely for fun: I want to walk the whole path myself and understand every step.
URL → DNS → TCP → TLS → HTTP/1.1 request → HTTP/1.1 response → parser → response
It's not trying to replace Req, Finch or Mint. If I have the time, it may grow into a proper client someday. For now it's a hobby project.
The request encoding, the response parser and body framing are written by
hand. Sockets and TLS come from OTP (:gen_tcp, :ssl). No runtime
dependencies.
Design notes, rules and roadmap live in AGENTS.md.
Installation
def deps do
[
{:fetch, "~> 0.1"}
]
end
Requires Elixir 1.15+ and OTP 25+.
Basic usage
{:ok, response} = Fetch.get("https://www.erlang.org/")
response.status
#=> 200
Request
Fetch.request(:get, "http://localhost:4000/users?page=2",
headers: [{"accept", "application/json"}]
)
Fetch.post("http://localhost:4000/users",
headers: [{"content-type", "application/json"}],
body: ~s({"name":"Jon Snow"})
)
Helpers: get/2, head/2, post/2, put/2, patch/2, delete/2.
request/3 also accepts :options.
Bytes sent for the POST above:
POST /users HTTP/1.1
host: localhost:4000
user-agent: fetch/0.1.0
content-type: application/json
content-length: 19
connection: close
{"name":"Jon Snow"}
host, content-length, transfer-encoding and connection are managed by
the client. Header names and values are validated, so a value with \r\n
returns {:error, {:request, {:invalid_header_value, name}}} instead of
injecting headers.
Response
%Fetch.Response{
status: 200,
headers: [{"content-type", "text/html"}, {"set-cookie", "a=1"}, {"set-cookie", "b=2"}],
body: "<!doctype html>..."
}
Fetch.Response.get_header(response, "Set-Cookie")
#=> ["a=1", "b=2"]
Header names are lowercased; order and duplicates are kept. Any status code,
including 4xx and 5xx, is {:ok, response}.
The body is read whole, whether it is framed by content-length,
transfer-encoding: chunked or by the server closing the connection.
Errors
{:error, {:url, {:unsupported_scheme, "ftp"}}}
{:error, {:dns, :nxdomain}}
{:error, {:connect, :econnrefused}}
{:error, {:tls, {:tls_alert, {:unknown_ca, _}}}}
{:error, {:recv, :timeout}}
{:error, {:parse, {:invalid_header, "Content-Length : 5"}}}
{:error, {:redirect, :too_many_redirects}}
Stages: :url, :request, :dns, :connect, :tls, :send, :recv,
:parse, :redirect. Unknown options and unsupported methods raise
ArgumentError.
HTTPS
TLS is OTP :ssl on top of the connected TCP socket, with:
verify: :verify_peeragainst the OS trust store (:public_key.cacerts_get/0)- SNI and hostname verification (wildcard certificates supported)
Extra :ssl options are merged over these defaults, e.g. a private CA:
Fetch.get("https://internal.test/", ssl: [cacertfile: "priv/ca.pem"])
Redirects
301, 302, 303, 307 and 308 are followed by default, at most 10 times:
Fetch.get("http://www.erlang.org/") # 301 → https://www.erlang.org/
Fetch.get(url, follow_redirects: false) # returns the 3xx response itself
Fetch.get(url, max_redirects: 3) # {:error, {:redirect, :too_many_redirects}}
| status | next request |
|---|---|
| 301, 302 | POST becomes GET without body, other methods are kept |
| 303 | GET without body (HEAD stays HEAD) |
| 307, 308 | same method, headers and body |
A relative location is resolved against the current URL. When a redirect
goes to another origin (scheme, host or port), authorization,
proxy-authorization and cookie headers are not sent there.
Keep-alive
Fetch.get/2 and friends use a new connection for every request.
Fetch.Conn keeps one connection open and sends requests over it one after
another:
{:ok, conn} = Fetch.Conn.new("http://localhost:4000")
{:ok, conn, users} = Fetch.Conn.request(conn, :get, "/users")
{:ok, conn, created} = Fetch.Conn.request(conn, :post, "/users", body: ~s({"name":"Jon Snow"}))
Fetch.Conn.close(conn)
A connection is a value, not a process: every request returns the updated
connection, keep using that one. Errors are {:error, conn, {stage, reason}}.
new/2does no IO; the first request connects.- The connection is closed after a response with
connection: close, an HTTP/1.0 response, a body delimited by close, or any error. The next request connects again. - If the server closed the idle connection, the next request notices before sending anything and reconnects.
- If the connection breaks after a request was sent, you get the error. There is no automatic retry: the server may already have handled the request.
keep_alive: falsesendsconnection: closefor the last request.- No redirects, no pooling, no pipelining. Use a connection from the process that created it.
Timeouts
| option | default | |
|---|---|---|
:connect_timeout |
5_000 |
each of DNS lookup, TCP connect, TLS handshake |
:receive_timeout |
15_000 |
max silence while waiting for bytes |
:max_body_size |
16 MiB | larger bodies fail with {:recv, :body_too_large} |
There is no total request deadline: a server that keeps sending a byte now and
then is not stopped by :receive_timeout.
Limitations
Fetch.get/2and friends open a new connection per request; reuse needsFetch.Conn. No pooling, no pipelining, no retries.- The whole body is kept in memory. No streaming.
chunkedis the only transfer coding.gzip, chunkedand friends return{:error, {:parse, {:unsupported_transfer_encoding, value}}}.- Trailer fields of chunked responses are validated and dropped.
- No compression, cookies, proxies, retries.
- Every redirect opens a new connection. The response does not say which URL it finally came from.
- HTTP/1.1 only.
Roadmap
- Streaming responses
- Optional: gzip/deflate, benchmarks
Why not Req/Finch?
For real work, use them. Req is the high-level client, Finch adds pooling, and Mint is the process-less protocol layer they are built on. Fetch exists to learn what those libraries do and why.
Try it
mix run examples/get.exs https://www.erlang.org/
mix run examples/keep_alive.exs https://www.erlang.org/ 10
mix test # local servers only
mix test --include external # also talks to the internet