Andrey Listopadov

fnl-http inside look

@talks ~5 minutes read

An HTTP client for Fennel written from scratch

  • Supports HTTP/1.1
  • Async operations
  • Streaming support
  • Sever implementation included

Installation & Usage

Via deps:

;; deps.fnl
{:deps
 {:io.gitlab.andreyorst/fnl-http
  {:type :git :sha "20fb5964c095d4b654a993bd4edf4a187be5814e"}}}

Usage:

;; main.fnl
(local {: client : json : readers : url}
  (require :io.gitlab.andreyorst.fnl-http))

Quick Overview

Main module provides:

  • Basic HTTP methods:
    • get
    • post
    • put
    • patch
    • options
    • trace
    • head
    • delete
    • connect
  • JSON support
    • encode
    • decode
  • Readers for data streaming
    • file-reader
    • string-reader
    • ltn12-reader
  • URL
    • URL
    • parse-url
    • urlencode
  • Asyncronous HTTP/1.1 server

Quick Overview: Client

  • Simple GET request
(client.get "http://httpbin.org/get")
{:body
 "{
  \"args\": {},
  \"headers\": {
    \"Host\": \"httpbin.org\",
    \"X-Amzn-Trace-Id\": \"Root=1-69502e62-09eb17c1577018c44a63ae39\"
  },
  \"url\": \"http://httpbin.org/get\"
}
"
 :headers
 {:Access-Control-Allow-Credentials "true"
  :Access-Control-Allow-Origin "*"
  :Connection "keep-alive"
  :Content-Length "198"
  :Content-Type "application/json"
  :Date "Sat, 27 Dec 2025 19:07:14 GMT"
  :Server "gunicorn/19.9.0"}
 :http-client #<tcp-client: 0xb75126980>
 :length 198
 :protocol-version {:major 1 :minor 1 :name "HTTP"}
 :reason-phrase "OK"
 :request-time 128
 :status 200
 :trace-redirects {}}
  • GET request with body parsing
(client.get "http://httpbin.org/get" {:as :json})
{:body {:args {}
        :headers
        {:Host "httpbin.org"
         :X-Amzn-Trace-Id "Root=1-69502e67-30e92b046c210466678b579a"}
        :url "http://httpbin.org/get"}
 :headers
 {:Access-Control-Allow-Credentials "true"
  :Access-Control-Allow-Origin "*"
  :Connection "keep-alive"
  :Content-Length "198"
  :Content-Type "application/json"
  :Date "Sat, 27 Dec 2025 19:07:19 GMT"
  :Server "gunicorn/19.9.0"}
 :http-client #<tcp-client: 0xb7537dc80>
 :length 198
 :protocol-version {:major 1 :minor 1 :name "HTTP"}
 :reason-phrase "OK"
 :request-time 149
 :status 200
 :trace-redirects {}}
  • GET request with body as a stream
(client.get "http://httpbin.org/get" {:as :stream})
{:body #<Reader: 0xb75123f00>
 :headers
 {:Access-Control-Allow-Credentials "true"
  :Access-Control-Allow-Origin "*"
  :Connection "keep-alive"
  :Content-Length "198"
  :Content-Type "application/json"
  :Date "Sat, 27 Dec 2025 19:07:23 GMT"
  :Server "gunicorn/19.9.0"}
 :http-client #<tcp-client: 0xb7514ebc0>
 :length 198
 :protocol-version {:major 1 :minor 1 :name "HTTP"}
 :reason-phrase "OK"
 :request-time 147
 :status 200
 :trace-redirects {}}
  • Simple POST request
(client.post
 "http://httpbin.org/post"
 {:body "{\"foo\": \"bar\"}"
  :headers {:content-typa :application/json}
  :as :json})
{:body {:args {}
        :data "{\"foo\": \"bar\"}"
        :files {}
        :form {}
        :headers
        {:Content-Length "14"
         :Content-Typa "application/json"
         :Host "httpbin.org"
         :X-Amzn-Trace-Id "Root=1-69502f27-2e169c092ee99e653f82bf5e"}
        :json {:foo "bar"}
        :url "http://httpbin.org/post"}
 :headers
 {:Access-Control-Allow-Credentials "true"
  :Access-Control-Allow-Origin "*"
  :Connection "keep-alive"
  :Content-Length "368"
  :Content-Type "application/json"
  :Date "Sat, 27 Dec 2025 19:10:31 GMT"
  :Server "gunicorn/19.9.0"}
 :http-client #<tcp-client: 0xb75308840>
 :length 368
 :protocol-version {:major 1 :minor 1 :name "HTTP"}
 :reason-phrase "OK"
 :request-time 139
 :status 200
 :trace-redirects {}}

Multipart support

  • Multipart can be sent:
(let [file (io.open "deps.fnl" :r)
      file-size (file:seek :end)
      _ (file:seek :set)
      text-to-stream "streamed text"]
  (-> (client.post "http://httpbin.org/post"
                   {:as :json
                    :multipart
                    [{:name "text" :content "text data"}
                     {:name "text-stream"
                      :content (readers.string-reader text-to-stream)
                      :length (length text-to-stream)}
                     {:name "file"
                      :content file
                      :length file-size
                      :filename "deps.fnl"
                      :mime-type "text/plain"}]})
      (. :body)))
{:args {}
 :data ""
 :files
 {:file
  ";; deps.fnl
{:deps
 {:io.gitlab.andreyorst/fnl-http
  {:type :git :sha \"20fb5964c095d4b654a993bd4edf4a187be5814e\"}}}
"}
 :form {:text "text data" :text-stream "streamed text"}
 :headers
 {:Content-Length
  "794"
  :Content-Type
  "multipart/form-data; boundary=------------f77e6d9d-9cac-4824-98f6-d1e08cb5e1f9"
  :Host
  "httpbin.org"
  :X-Amzn-Trace-Id
  "Root=1-69502f48-26a3f99c51252b4f7ef4a423"}
 :json nil
 :url "http://httpbin.org/post"}
  • Can be received too, but I’m lazy to write an example

Quick Overview: JSON

  • Encoding
(json.encode {:foo :bar})
{"foo": "bar"}
(json.encode [1 2 3])
[1, 2, 3]
  • Decoding
(json.decode "{\"foo\": \"bar\"}")
{:foo "bar"}
(json.decode "{\"foo\": null}")
{:foo nil}
  • NULL handling
(json.encode (json.decode "{\"foo\": null}"))
{"foo": null}

Quick Overview: Readers

  • Create a reader from a string:
(local rdr (readers.string-reader "foo\nbar\nbaz"))
rdr
#<Reader: 0xb75261040>
  • Read fixed amount of bytes
(rdr:read 4)
foo\n
  • Create an iterator over lines
(icollect [l (rdr:lines)] l)
["bar" "baz"]

Quick Overview: Url module

  • Parses:
    • scheme
    • host
    • port
    • userinfo
    • path
    • query and fragment
(url.parse "http://httpbin.org/get")
{:host "httpbin.org" :path "/get" :port 80 :scheme "http"}
  • Supports tostring
(tostring (url.parse "http://httpbin.org/get"))
http://httpbin.org:80/get
  • Constructs URL from tables:
(url.URL {:scheme "http" :host "httpbin.org"})
http://httpbin.org

Async requests

  • Async pattern is supported by specifying async? parameter:
(var body nil)
(client.get
 "http://httpbin.org/get"
 {:async? true :as :json}
 (fn on-success [resp]
   (set body resp.body))
 (fn on-error [resp]
   (error resp.body)))
#<ManyToManyChannel: 0xb7523b980>
  • Operations happen in the background:
(while (= nil body))
body
{:args {}
 :headers
 {:Host "httpbin.org"
  :X-Amzn-Trace-Id "Root=1-69502947-54f1de6e7a8da8b952407dbe"}
 :url "http://httpbin.org/get"}

Why async?

  • Async requests:
(local {: chan : >! : <!! : take : reduce}
  (require :io.gitlab.andreyorst.async))

(time
 (let [n-req 10
       lengths (chan n-req)]
   (for [i 1 n-req]
     (client.post
      "http://httpbin.org/post"
      {:body (.. "foo" i) :async? true}
      (fn [resp] (>! lengths (length resp.body)))
      #nil))
   (print "total length:" (<!! (reduce #(+ $1 $2) 0 (take n-req lengths))))))
total length:	2941
Elapsed	1762.2859477997 ms
  • Sequential requests:
(time
 (let [n-req 10
       total (faccumulate [res 0 i 1 n-req]
               (let [resp (client.post
                           "http://httpbin.org/post"
                           {:body (.. "foo" i)})]
                 (+ res (length resp.body))))]
   (print "total length:" total)))
total length:	2941
Elapsed	3597.295999527 ms

Luasocket’s HTTP vs fnl-http

  • Sequential requests:
(local lhttp (require :socket.http))
(time
 (let [n-req 10
       total (faccumulate [res 0 i 1 n-req]
               (let [resp (lhttp.request "http://httpbin.org/post" (.. "foo" i))]
                 (+ res (length resp))))]
       (print "total length:" total)))
total length:	4041
Elapsed	5041.1369800568 ms

Look Inside

  • Powered by async.fnl
  • Implements sockets as bi-directional, non-blocking async channels
  • Each socket is backed by two asyncronous threads

Socket Channel

  • Combo channel with sender and receiver
  • Puts are handled via the sender thread, takes via the receiver thread
  • Sender thread
    • Checks if socket is ready to accept via select, sleeps 10ms otherwise
    • Sends data
    • If timeout, sends remaining data after socket is ready again
    • Loops until closed
  • Receiver thread
    • Tries to read a “chunk”
    • If timeout, sleeps 10ms, collects partially received data, if any
    • Loops until closed
  • Closing either one closes the other one
  • Closing the socket closes both sender and receiver

Parser

  • Implemented from scratch
  • Response parser:
    • response status line
    • headers
    • content
      • multipart
      • chunked
      • raw
  • Request parser:
    • request status line
    • headers
    • content
      • multipart
      • stream
      • chunked

Body reader

  • Chunked reader
  • Multipart reader implemented as Lua iterator
    • Each part is a stream to be consumed
    • Advancing the iterator consumes remaining data from the part

Server

(server.start request-handler {:host "localhost" :port 1234})
  • Each client is assigned to its own asychronous thread
  • Handler receives a parsed HTTP request

Server expample: SSE

(fn wrap-format-sse-event [ch]
  (let [out (chan)]
    (go-loop [e (<! ch)]
      (if e
          (do (doto out
                (>! (.. "event: " (tostring event) "\n"))
                (>! (.. "id: " (tostring id) "\n"))
                (>! (.. "data: " (tostring data) "\n")))
              (recur (<! ch)))
          (close! out)))
    out))

(fn wrap-heartbeat [ch timeout-ms]
  (let [out (chan)]
    (go-loop []
      (let [tout (timeout timeout-ms)]
        (match (alts! [tout ch])
          [_ tout]
          (do (>! out {:event "heartbeat" :data 0})
              (recur))
          [event ch]
          (do (>! out event)
              (recur))
          [nil ch] (close! out))))
    out))

(fn handler [{: path : method : headers : content}]
  (case path
    (where "/events" (= :GET method))
    (let [events (chan)]
      (subscribe-to-events events)
      {:status 200
       :headers {:content-type "text/event-stream"}
       :body (-> events
                 (wrap-heartbeat 10_000)
                 wrap-format-sse-event)})
    (where "/notify" (= :POST method))
    (case (tonumber headers.Content-Length)
      len (let [event (json.decode (content:read len))]
            (register-event event)
            {:status 200
             :body "OK"})
      _ (error "malformed Content-Lenght"))
    _
    {:status 405
     :headers {:content-type "application/json"}
     :body (json.encode {:error "Method not allowed"})}))

(server.start handler {:port 12345})

Thanks!