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. There are no dependencies but Clojure.
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.
The library requires Clojure 1.11+ and Java 11+. For the latest release,
add it from Clojars to the
:deps in your deps.edn:
dk.simongray/wary-fetch {:mvn/version "0.2.0"}
For changes that aren't released yet, use the SHA of the latest commit on
master instead:
dk.simongray/wary-fetch
{:git/url "https://github.com/simongray/wary-fetch"
:git/sha "…"}
For ClojureScript, shadow-cljs only reads Git dependencies from
deps.edn, so also set :deps true in your shadow-cljs.edn.
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. In ClojureScript,
http/fetch! gives a promise of the response.
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
A URL on the network of the app or of its device gets no request, and
http/fetch! throws an ex-info with the :type
:dk.simongray.wary-fetch/refused 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 of your own, e.g. (constantly true).
A request is a map of :method, :url, :headers and :body. Send one
with http/send!, or let http/fetch! make one for you. A request 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: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 :bytes, for a body of bytes, e.g. of an image:range, the first and the last byte to ask forAn exchange that goes beyond a limit throws with the :type
:dk.simongray.wary-fetch/timeout or :dk.simongray.wary-fetch/too-large.
To tell whether an exception is a timeout, including one from the
platform's own client, use http/timeout?.
Use http/send-public! to send a request with url/public? as its
:allow-pred, and http/try-send! to get the exception in a map instead
of a throw. The http/failure function gives an exception as data.
The functions that make a request for you, e.g. http/fetch!, take the
limits, :allow-pred and :user-agent as options, and
http/with-options puts them on a request of your own. Unlike
http/send!, they take url/public? as the :allow-pred by default.
The http/fetch! function also takes :credentials and :accept, and
:send for a function that sends the request in place of http/send!.
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 that the request gets is hidden too. Use
http/revealed to get a request with the texts of its hidden headers,
e.g. for another HTTP client.
A response from http/fetch! has the :not-before instant if its
Retry-After, Cache-Control or Expires header says when to ask again. To
get the same 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 the wait with :max-wait-seconds, which both functions
take, e.g. 3600 for an hour.
The dk.simongray.wary-fetch.headers namespace has functions for the
other headers of a response too, e.g. headers/links for a Link header
and headers/media-type for a Content-Type.
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}
;; …}}
Use http/fetcher to read a file in parts by byte range, and, on the
JVM, http/digest! to get a SHA-256 or other 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. 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 in its url parameter, as long as 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 the response is
what a direct request would give:
(http/fetch! "https://example.com/feed.xml"
{:send (proxy/sender "/proxy")})
The handler's options, e.g. which headers it passes on, are in
proxy/defaults.
The functions that send give promises in ClojureScript. To write code for
Clojure and ClojureScript at once, chain them with async/then, which
takes a value, a future or a promise alike. The
dk.simongray.wary-fetch.async namespace has a few more functions like
it.
In Node, http/send! sends with node:http, and its guards are those of
the JVM. It looks up a name before it asks the :allow-pred, so that
url/public? checks every address of the name, and the request then
connects only to those addresses. It follows the redirects itself, and
asks the :allow-pred about each.
In a browser, http/send! sends with 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 :filtered? marks such a
response.
.cljc, with no dependencies but Clojure, and it behaves
the same everywhere, as far as JavaScript allows.clojure -X:test # the tests on the JVM
npm install # once, for the Node tests
clojure -M:cljs compile test # the tests in Node
The public namespaces are the root, headers, url, proxy, async
and secret. The others are internal: jvm, node and browser are
the clients of each platform, redirect and limits hold the rules
that the clients share, and date, encoding and bytes are helpers.
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 |