Consume external APIs safely with blocking and asynchronous HTTP clients, deliberate timeout policy, and explicit handling of JSON, retries, and failure modes.
HTTP integration in Clojure is mostly about operational discipline: timeouts, retries, payload shaping, and clear failure handling. The library choice matters, but the larger question is whether the call is blocking or asynchronous, whether it is safe to retry, and how much downstream instability your application can absorb.
clj-http is convenient when a blocking request fits the control flow and the service can afford to wait for the result. http-kit becomes attractive when you want lightweight asynchronous calls or you are already building around callback- or channel-style handoff.
The mistake is not choosing the “wrong” library. The mistake is choosing a request model that does not match the operational behavior of the dependency.
For a normal blocking integration, you usually want:
1(ns myapp.http.blocking
2 (:require [cheshire.core :as json]
3 [clj-http.client :as client]))
4
5(defn fetch-customer [customer-id auth-token]
6 (let [response (client/get
7 (str "https://api.example.com/customers/" customer-id)
8 {:accept :json
9 :as :json
10 :headers {"authorization" (str "Bearer " auth-token)}
11 :socket-timeout 2000
12 :conn-timeout 1000
13 :throw-exceptions false})]
14 (case (:status response)
15 200 {:status :ok :customer (:body response)}
16 404 {:status :not-found}
17 {:status :upstream-error
18 :http-status (:status response)
19 :body (:body response)})))
The key design choice is :throw-exceptions false. That keeps ordinary non-2xx HTTP outcomes in the value path rather than mixing them with true transport exceptions.
http-kitWhen you need fan-out or do not want to block the caller thread, http-kit can express that more directly.
1(ns myapp.http.async
2 (:require [org.httpkit.client :as http]))
3
4(defn fetch-customer-async [customer-id auth-token callback]
5 (http/get
6 (str "https://api.example.com/customers/" customer-id)
7 {:timeout 2000
8 :headers {"authorization" (str "Bearer " auth-token)
9 "accept" "application/json"}}
10 (fn [{:keys [status body error]}]
11 (cond
12 error
13 (callback {:status :transport-error
14 :error error})
15
16 (= status 200)
17 (callback {:status :ok
18 :body body})
19
20 :else
21 (callback {:status :upstream-error
22 :http-status status
23 :body body})))))
Asynchronous code should not just “look faster.” It should exist because the workflow actually benefits from non-blocking behavior.
Every outbound HTTP call is a boundary to an unreliable system.
sequenceDiagram
participant APP as Clojure App
participant CLIENT as HTTP Client
participant API as Remote API
APP->>CLIENT: Build request with timeout and auth
CLIENT->>API: Send request
API-->>CLIENT: 200 / 4xx / 5xx / timeout
CLIENT-->>APP: Structured success or failure result
Useful distinctions:
Those outcomes should not all collapse into the same generic “API error.”
Retries are only safe when the operation is idempotent or the remote API supports an idempotency key. Blind retries on POST requests can create duplicate side effects.
Good retry questions:
If the answer is unclear, the safest policy is usually to avoid automatic retries or to retry only a narrow class of transport failures.
The real pattern is not “call API with library X.” The pattern is “treat external HTTP as a failure-prone integration boundary.”