This is a Clojure and ClojureScript library for fetching URLs that strangers give you, e.g. a feed URL that a user types in, or a link in a document you fetched. Requests and responses are plain maps.
The same code runs on the JVM, in Node and in the browser, where a proxy on your own server lets a page fetch from sites that don't allow it. It depends only on whatwg-url and whatwg-mime, which read URLs and MIME types the way browsers do, and on webcrypto-clj for bytes and their encodings.
This library was spun out of podcast-clj, a library for making podcast software, where it fetches feeds, media files and the rest of what a listener subscribes to. Like podcast-clj, it was developed with assistance from frontier LLMs.
It requires Clojure 1.11+ and Java 11+. Add it from
Clojars to the :deps in
your deps.edn:
dk.simongray/wary-fetch {:mvn/version "0.14.1"}
For unreleased changes, depend on the latest commit on master instead.
In shadow-cljs, a Git dependency only works through deps.edn, so set
:deps true in your shadow-cljs.edn too.
Fetch a document with http/fetch!:
(require '[dk.simongray.wary-fetch :as http])
(def response
(http/fetch! "https://example.com/"))
(select-keys response [:status :url :last-modified])
;; => {:status 200
;; :url "https://example.com/"
;; :last-modified "Fri, 09 Oct 2026 20:19:47 GMT"}
The response also has the headers, and the body as text, decoded by the
charset that the server or the document declares. Each header name is in
lower case, with a vector of its values, so read a header with
headers/header. In ClojureScript, you get a promise of the response.
A URL on your own machine or network gets no request. It throws instead:
(http/fetch! "http://169.254.169.254/latest/meta-data")
;; throws "The request to http://169.254.169.254/latest/meta-data is not allowed"
The check is the :allow-pred option, a predicate of the URL and of each
redirect, which is url/public? unless you give another. To fetch from
your own network, give one that allows it, e.g. (constantly true).
Give the next request the :etag and :last-modified of the response,
and a document that hasn't changed comes back as a 304 without a body:
(:status (http/fetch! "https://example.com/"
(select-keys response [:etag :last-modified])))
;; => 304
When the server says how long to wait, in a Retry-After, Cache-Control or
Expires header, the response of http/fetch! also has the instant to
ask again as :not-before. To get that instant from the headers of any
response, use headers/not-before:
(require '[dk.simongray.wary-fetch.headers :as headers])
(headers/not-before {"cache-control" "max-age=600"}
#inst "2026-10-10T12:00:00Z")
;; => #inst "2026-10-10T12:10:00.000-00:00"
The wait is as long as the server says. To poll a server that might be
set up wrong, cap it with the :max-wait-seconds option of either
function, e.g. 3600 for an hour.
The headers namespace reads the other headers of a response too, e.g.
a Link header with headers/links. For a Content-Type, use whatwg-mime.
A request is a map of :method, :url, :headers and :body, which
you send with http/send!. It can also have these keys:
:timeout-seconds, the limit of the whole exchange, 30 by default:max-bytes, the limit of the body, 100 MB by default:truncate?, true to cut a body over that limit rather than throw,
which marks the response :truncated?:max-redirects, how many redirects to follow, 5 by default:allow-pred, a predicate of the URL and of each redirect:user-agent, the User-Agent, unless the headers name one:as, either :bytes for a body of bytes, e.g. of an image, or
:json for the value of a JSON body:range, the first and the last byte to ask for:sink, to take the body in pieces, see Media files:signal, to stop the exchange, e.g. when the user cancels: an
AbortSignal in ClojureScript, or on the JVM an atom that turns true:prepare-fn, a function that returns the request of each hop, the
first and each redirect, e.g. to sign it for its own URL, as
httpsig-clj doesA request to any URL goes through, unless its :allow-pred says
otherwise. To send one to the public internet only, as http/fetch!
does, use http/send-public!. An answer with an error status, e.g. a
404, is a response like any other.
The functions that make a request for you take its limits,
:allow-pred and :user-agent as options, and http/with-options puts
the same options on a request of your own. Besides those,
http/fetch! takes :credentials, :accept, and a :send that sends
its request in place of http/send!, e.g. through the
proxy.
The body of a request is text or bytes. To make one from data, build the request with one of these:
http/form-request for a form, e.g. to an OAuth token endpoint. It
hides the body, since a form often holds a secret.http/json-request for a value as JSON.http/multipart-request for files beside the fields of a form, e.g. a
photo to upload.A map or another collection as the body throws, rather than be sent as
its printed text. The json namespace reads and writes JSON on every
platform, with no library to add. For the hashes, HMACs and random bytes
that signatures and tokens need, use webcrypto-clj.
Hide a password or a token with secret/hide, and it prints as
#<secret>:
(require '[dk.simongray.wary-fetch.secret :as secret])
(def credentials
{:username "me" :password (secret/hide "hunter2")})
credentials
;; => {:username "me", :password #<secret>}
(http/fetch! "https://example.com/private.xml" {:credentials credentials})
The Authorization header of the request is hidden too, and it stays
behind when a redirect leaves the site. For another HTTP client, get the
texts of the hidden headers back with http/revealed.
A token that you read from a file often ends with a line break, which HTTP doesn't allow in a header, so trim it first. Otherwise the request throws, and the exception names the header but not the token.
A request that fails throws an ex-info, or in ClojureScript rejects with
one. Its :type says why, as a keyword in the dk.simongray.wary-fetch
namespace:
:allow-pred refused the URL or a redirect.:timeout-seconds, or the body held more than its :max-bytes.:signal stopped the exchange.A browser doesn't say why a request failed, but when it knows that the device is offline, the type is unreachable.
To get the exception in a map rather than as a throw, use
http/try-send!. On the JVM, it gives the map in a future, so that
several requests can be under way at once. To turn an exception into
data, use http/failure, and to tell a timeout, including one from the
platform's own client, use http/timeout?.
To ask about a large file without downloading it, use http/probe!,
which sends a HEAD request and a request for the first two bytes:
(http/probe! "https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg")
;; => {:head {:status 200
;; :type "application/ogg"
;; :length 104793
;; …}
;; :get {:status 206
;; :range {:start 0 :end 1 :total 104793}
;; …}}
To download a file without holding it in memory, give the request a
:sink, which can be:
The response then has no body, but the number of bytes that the sink
:received:
(require '[clojure.java.io :as io])
(with-open [out (io/output-stream "Example.ogg")]
(:received
(http/send-public!
{:url "https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg"
:sink out
:max-bytes nil})))
;; => 104793
The limits apply to the sink too, which is why the example sets
:max-bytes to nil, for a file that can be larger than 100 MB. Only the body of
a success goes to the sink, so a 404 still gives its page as the body.
To resume a download that broke off, ask for the rest with a :range
and an If-Range header, which headers/if-range makes from the headers of
the first answer. It's nil when they can't tell whether the file is still
the same, and the download then starts again. To read a file in parts by
byte range, use http/fetcher. On the JVM, http/digest! gives a SHA-256
or another digest of a file as it streams past.
A web page can only read an answer from another site when that site
allows it with CORS headers, and most don't. To fetch from them anyway,
make a Ring handler with proxy/handler, and mount it on your own
server, behind your login, at a route such as /proxy:
(require '[dk.simongray.wary-fetch.proxy :as proxy])
(def proxy-handler
(proxy/handler))
The handler fetches the URL of its url parameter, if that URL is on the
public internet, and answers with what came back. In the page, send each
request through it with proxy/sender, and you get the response that a
direct request would give:
(http/fetch! "https://example.com/feed.xml"
{:send (proxy/sender "/proxy")})
The options of the handler, e.g. which headers it passes on, are in
proxy/defaults.
The handler holds each body in memory, so it passes on 32 MB at most.
For a large file, e.g. an episode that a page saves, mount
proxy/stream-handler as well. It passes the body on as it arrives, and
a Range header too, so that a download can resume. Its options are in
proxy/stream-defaults.
The functions that send give promises in ClojureScript. To write code for
both platforms at once, chain them with async/then, which takes a
value, a future or a promise alike. The async namespace has a few more
functions like it.
In Node, requests go through node:http, with the guards of the JVM. The
library looks up the name of the host before it asks the :allow-pred,
so that url/public? checks every address of the name, and then connects
only to those addresses. It also follows the redirects itself, and asks
the :allow-pred about each.
In a browser, requests go through fetch, and two of the guards are weaker:
url/public? checks the name itself,
and refuses e.g. localhost and names under .local.:allow-pred doesn't see them.Those guards matter less in a page, since its requests come from the
user's own device rather than from your server. CORS also hides the
headers that a server doesn't expose, and the response is then marked
:filtered?.
In a Capacitor app, CORS applies to the web view too, so send through the native HTTP of the CapacitorHttp plugin instead. It has the guards of Node, except the lookup of names:
(require '["@capacitor/core" :refer [CapacitorHttp]]
'[dk.simongray.wary-fetch.capacitor :as capacitor])
(http/fetch! "https://example.com/feed.xml"
{:send (capacitor/sender CapacitorHttp)})
The plugin hands over each body whole, so leave large files, e.g. episodes, to a plugin for file transfers.
In an Electron app, the page sends its requests through the main process,
where Node sends them with all its guards. Give the main process
electron/handler and the page electron/sender, as the docstring of
the electron namespace shows. The handler keeps its own limits,
whatever a page asks for, so a page can't reach the network of the
computer.
.cljc, and it behaves the same everywhere, as far as
JavaScript allows. It reads each URL and its host with whatwg-url, so a
host that a browser would read as an IP address, e.g. 0x7f.1, is checked
as one.clojure -X:test # the tests on the JVM
npm install # once, for ClojureScript
clojure -M:cljs compile test # the tests in Node
clojure -M:cljs compile browser-test # the tests in a browser
node test/browser-run.mjs # run them in a headless Chrome
Every namespace is public, except the client of each platform, the
rules that the clients share and a few helpers, which are marked
^:no-doc.
The wary-fetch project is licensed under the MIT licence.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |