Managed HTTP¶
:rf.http/managed is the one effect for talking to a
server. Describe the request as data; the runtime decodes, classifies failures,
retries, and cancels; the reply arrives as an ordinary event.
Optional artefact
Require re-frame.http.managed once at boot — Maven day8/re-frame2-http. Without
it, [:rf.http/managed …] raises :rf.error/no-such-fx, and
rf/reg-http-interceptor raises :rf.error/http-artefact-missing.
The args map¶
:rf.http/managed takes one args map. Two things in it are required: the :request, with a :url, and a reply target, which is :reply-to or the :on-success / :on-failure pair. Everything else has a default, so the common case stays short:
(ns app.article-http
(:require [re-frame.core :as rf]
[re-frame.http.managed]))
{:fx [[:rf.http/managed
{:request {:url "/api/articles/intro"}
:on-success [:article/loaded]
:on-failure [:article/load-error]}]]}
The API reference lists every key, value and default.
The request is a map¶
:request describes what goes over HTTP. To post a comment, add a method and
JSON body to the article request:
(rf/reg-event :comment/create
(fn [_ [_ slug text]]
{:fx [[:rf.http/managed
{:request {:method :post
:url (str "/api/articles/" slug "/comments")
:params {:notify true}
:headers {"X-Client" "web"}
:body {:comment {:body text}}
:request-content-type :json}
:decode :json
:on-success [:comment/created]
:on-failure [:comment/create-error]}]]}))
:params is URL-encoded onto the URL; :headers maps names to strings.
:request-content-type :json serializes the body and sets Content-Type.
For a browser file upload, pass js/FormData as the body and leave that key off
so the platform supplies the multipart boundary. The request reference
records the other body encodings and transport options.
To put the same header on every request (auth, say), register one HTTP interceptor instead of threading it through each call site; the interceptor also picks up a rotated token.
Handling the reply¶
Your handler receives a reply map with one of three statuses:
:status |
What the handler reads |
|---|---|
:ok |
:value is the decoded 2xx body, or the value returned by :accept. :meta carries the HTTP status, status text and headers under lower-cased names. |
:error |
:error is the failure map. Branch on its :kind. |
:cancelled |
:error has :kind :rf.http/aborted; the reply also carries :cancelled? true and :rf.reply/cancel-reason. |
An obsolete completion is recorded as :stale and suppressed before dispatch.
Your handler never receives it. Managed HTTP does not emit :partial, the
uniform reply's status for protocols that return usable data with problems.
Read :value on success and :error on failure. The reply reference
records timing and identity fields; Why no await
explains the shared async reply model.
Use separate receiving events for success and failure. A single :reply-to
handler is useful when the outcomes share cleanup; Advanced
shows that form.
Coming from Promises?
:on-success / :on-failure are .then / .catch; a single :reply-to handler that branches on :status is the .finally-plus-both-branches shape (a let above a case). Coming from Promises maps the whole triad, including why there is no :on-finally.
Two handlers with :on-success / :on-failure¶
Name :on-success and :on-failure and each outcome lands in its own handler, with the reply map appended as the last event argument — [:article/loaded {:status :ok :value <decoded> …}]. Both handlers receive the same reply map; the two keys only choose which handler it goes to.
This page has no server, so the cell below installs stubs: intro answers with an article and missing with a 404. Click each button to send one request down each path:
(require '[re-frame.core :as rf]
'[re-frame.http.managed]
'[re-frame.http.test-support :as http-test-support])
(http-test-support/install-managed-request-stubs!
{[:get "/api/articles/intro"] {:reply {:ok {:title "Welcome" :body "The first article."}}}
[:get "/api/articles/missing"] {:reply {:failure {:kind :rf.http/http-4xx :status 404}}}})
(rf/reg-event :article/load
(fn [{:keys [db]} [_ slug]]
{:db (assoc-in db [:article :status] :loading)
:fx [[:rf.http/managed {:request {:url (str "/api/articles/" slug)}
:on-success [:article/loaded]
:on-failure [:article/load-error]}]]}))
(rf/reg-event :article/loaded
(fn [{:keys [db]} [_ {:keys [value]}]]
{:db (assoc db :article {:status :loaded :data value})}))
(rf/reg-event :article/load-error
(fn [{:keys [db]} [_ {:keys [error]}]]
{:db (assoc db :article {:status :error :error error})}))
;; Show the [:article] slice, with a button per article.
(rf/reg-sub :article (fn [db _] (:article db)))
(rf/reg-view article-view []
[:div
[:button {:on-click #(dispatch [:article/load "intro"])} "Load intro"]
[:button {:on-click #(dispatch [:article/load "missing"])} "Load missing"]
[:pre (pr-str @(subscribe [:article]))]])
;; :fx-overrides sends this frame's requests to the stubs. A real app leaves it out.
[rf/frame-root {:id :app/articles :fx-overrides {:rf.http/managed :rf.http/managed-test-stub}}
[article-view]]
missing reaches :article/load-error with a failure map whose :kind is :rf.http/http-4xx. The stubs answer at once, so the :loading status never shows.
Prefer this shape when the success and failure paths are substantial or diverge: each handler reads and tests on its own.
From re-frame v1
Your :http-xhrio-style success/failure events map straight onto :on-success / :on-failure — the migration page walks the translation.
Delivery rules¶
Each request names its receiving events. Reply targets are event vectors;
nil explicitly silences a reply. Use either :reply-to
or :on-success / :on-failure; combining the forms is invalid, even when
one key is nil. The args reference
records these checks.
The effect carries the issuing frame through to the reply. Your receiving event runs in that same frame without a separate callback or frame capture.
Failures are a closed set¶
The failure map always carries a :kind: a keyword from a fixed list of eight, never a string.
- No response at all.
:rf.http/transportis a network, DNS or connection error, or a:bodythat could not be prepared. In the browser, the same trouble against a cross-origin URL reads as:rf.http/cors, because Fetch reports a CORS refusal and a dropped connection the same way.:rf.http/timeoutmeans the per-attempt timeout fired. - An error status.
:rf.http/http-4xxand:rf.http/http-5xxcarry the:status,:headersand the raw:body. - A 2xx the app rejected.
:rf.http/decode-failuremeans your:decoderejected the body;:rf.http/accept-failuremeans your:acceptdid. - Cancelled.
:rf.http/aborted, with a:reasonsaying who cancelled::userfor a manual abort or:abort-signal,:actor-destroyedfor a machine actor's destroy. A supersession, a frame's teardown and an epoch restore abort too, but deliver no reply.
The API reference's failure categories table lists the extra keys each kind carries.
Branch on :kind, as in the tutorial's message function.
Two classification rules catch newcomers:
- Status is classified before the body is touched — decode runs only on 2xx. A JSON endpoint behind a load balancer that 404s with an HTML error page is
:rf.http/http-4xxwith the raw HTML at:body, not a decode failure: the decoder never ran. If you want the structured error body many APIs return alongside a 4xx, decode:bodyyourself in the failure branch — the framework hands you the bytes and the status, on purpose. - An empty (or whitespace-only) 2xx JSON body is not a decode failure — it's a parsed value of
nil. The bare204 No Contenta PUT or DELETE replies with succeeds::decode :jsonhands your:on-successthe canonical{:status :ok :value nil …}. A schema:decodethen decides whethernilis acceptable —[:maybe …]passes, a required:maprejects as an ordinary schema failure. Identical on the browser and the JVM, on purpose.
Validating the body with :decode¶
By default :decode is :auto, which sniffs the Content-Type: JSON for a JSON type, a string for text/*, and otherwise the raw binary body (a Blob in the browser, bytes on the JVM). Hand :decode a Malli schema, as the tutorial's Step 3 does, and a malformed 2xx body becomes a :rf.http/decode-failure routed to your failure handler. (Malli has to be in the build: see tutorial step 3.) A schema reads JSON only: a 2xx response that declares another Content-Type, such as application/edn, is a :rf.http/decode-failure whose :cause names the type, while a response with no Content-Type is parsed as JSON.
:decode also accepts a keyword (:json / :text / :blob / :array-buffer / :form-data) or a plain function (fn [text headers] decoded) when you need full control.
Retry: transport retry as data¶
For a read, a short retry policy can recover from a temporary failure:
:retry {:on #{:rf.http/transport :rf.http/http-5xx :rf.http/timeout}
:max-attempts 3
:backoff {:base-ms 200 :factor 2 :max-ms 2000 :jitter true}}
Add that entry to the HTTP args map. :max-attempts includes the first try, so
this allows two retries. Only the final exhausted failure dispatches your
failure handler; intermediate failures appear in traces. Cancellation also
stops a pending retry.
Leave automatic retry off writes unless the server provides an idempotency contract: a timeout or lost response does not prove the write failed. The retry reference records the allowed categories and backoff defaults.
When another attempt first needs an application decision, such as refreshing an auth token, coordinate it through event handlers or a machine. Transport retry resends the same request; it does not refresh that token.
Cancellation: supersession and abort¶
Supersession — reuse a :request-id. Give a request a stable :request-id — any =-comparable value: a keyword, a string, or a structural vector such as [:article/load slug] — and issuing a new request with the same id automatically supersedes the old one. Ids belong to the frame that issued them, so the same id in code mounted in two frames never collides, and no frame can supersede or abort another's request. The old reply is suppressed before delivery: your handler never sees it, only a trace row records it (:reason :request-id-superseded). For the tutorial's single article display, use the stable id :article/load;
including the slug would give each article a separate request that could not
supersede another article's load (latest article).
Frame teardown. A request issued from an ordinary event handler belongs to the frame that issued it. Destroying that frame, or restoring an earlier epoch in it, aborts every request it still has in flight, with or without a :request-id, and suppresses their replies; each leaves a :rf.http/stale-suppressed trace row whose :recovery (:suppressed-on-frame-destroy or :suppressed-on-epoch-restore) says which boundary it was. While the frame lives, :request-id is your app-level cancel handle.
Manual abort — [:rf.http/managed-abort the-id]. A manual abort stops whichever request currently holds the id and, unlike a supersession, does deliver a reply: a :status :cancelled reply carrying {:kind :rf.http/aborted :reason :user} under :error, so the handler for a user's cancel can clear the spinner. An id with nothing in flight (its reply has already landed, say) makes the abort a no-op.
Requests issued by a spawned actor are also cancelled when it is destroyed. HTTP from a machine explains that lifetime and which replies are suppressed.
The managed-http counter example demonstrates the manual-abort path end-to-end — plus the 404-is-not-a-decode-failure rule — in one small file.
Timeouts¶
Every attempt has a per-attempt timeout, default 30000 ms. Set :timeout-ms to change it; nil or 0 opts out entirely. A fired timeout classifies as :rf.http/timeout with :elapsed-ms and :limit-ms on the failure map, and is retryable under a :retry policy that includes it.
From a state machine¶
Use a state-bound HTTP request when leaving a machine state should cancel its work, or several requests must finish together. The machine form accepts the same args map, with the child managing its own reply address.
Testing without a network¶
Tests need no network. Require re-frame.http.test-support from test code and use either of its two tools:
with-request-stubsanswers requests from a route map while a function runs. It matches a request on its:methodand on its:urlas the:beforeinterceptors leave it, before:paramsis merged in. A request that matches no route receives a:rf.http/transportfailure whose:messageis"no stub matched".- The canned-stub effects,
:rf.http/managed-canned-successand:rf.http/managed-canned-failure, deliver one reply inline from:fx.
Both deliver the :status / :value / :error shape a live request does, without the live reply's identity and timing keys (:rf.reply/work-id, :completed-at). They supply an already-decoded reply: body encoding, :decode, :accept, retries, timeouts and request-id supersession do not run. Test those behaviors against a controlled HTTP endpoint. The tutorial's test step shows the pattern, Test a pipeline run is the full recipe, and the API reference documents every stub surface.
Troubleshooting¶
| You see | What happened |
|---|---|
:rf.error/no-such-fx naming :rf.http/managed, or :rf.error/http-artefact-missing from rf/reg-http-interceptor |
The artefact isn't loaded. Require re-frame.http.managed once at boot. |
:rf.error/fx-handler-exception on :rf.http/managed, its exception carrying :rf.error/http-no-reply-target, :rf.error/http-bad-reply-target, :rf.error/http-bad-request, :rf.error/http-bad-retry-on or :rf.error/schemas-artefact-missing |
The args map was refused and nothing was sent: no reply target, a misshaped or mixed reply target, a bad :url, a bad :retry :on, or a :decode schema with slot marks while re-frame.schemas isn't loaded. The error names the key. |
:rf.error/machine-action-exception from a spawned :rf.http/managed child, its :exception-data carrying :rf.error/http-bad-reply-target |
The spawn's :data carried :reply-to, :on-success or :on-failure, and nothing was sent. Drop the key: the child reports back as [:succeeded value] / [:failed failure]. See HTTP from a machine. |
An :error trace row named for a failure kind: :rf.http/timeout, :rf.http/http-5xx, … |
The request failed, and the same failure map reached your failure target. The row is the dev trace's record of the failure, not a sign it went unhandled. |
:rf.http/issued, later :rf.http/stale-suppressed, and no handler ran |
The reply was suppressed: a newer request took the :request-id, or the frame was destroyed or restored to an earlier epoch. See Cancellation. |
:rf.warning/failure-swallowed |
A failure had no reply target and was dropped. See Silencing a reply. |
:rf.error/http-interceptor-failed, :rf.error/http-interceptor-bad-return or :rf.error/http-reply-tail-failed |
An interceptor threw or returned a non-map, or delivering the reply threw after the response arrived. No reply is delivered. A :before failure also arrives as :rf.error/fx-handler-exception carrying the same id. |
:rf.error/no-frame-context from rf/reg-http-interceptor |
The registration names no frame. Pass :frame, or register inside a frame scope (Interceptors). |
:rf.warning/http-malli-absent, and a malformed body reached your handler |
Malli isn't in the build, so the :decode schema was skipped. See step 3 of the tutorial. |
:rf.warning/http-header-invalid |
A request header was rejected and the request went without it. See Malformed headers. |
:rf.http/cljs-only-key-ignored-on-jvm or :rf.http/binary-decode-degraded-on-jvm |
A browser-only option ran on the JVM. See Running on the JVM. |
:rf.error/bad-classification naming :rf.http/managed |
The :carriers block on your :rf.http/managed registration is malformed. See HTTP trace redaction. |
Every other :rf.http/* trace row, and what each carries, is in the API reference's trace table.
Advanced¶
One handler with :reply-to¶
A single receiving handler can share cleanup across success, failure and
cancellation (the cleanup example).
For a small operation, the sending and receiving event can also be the same.
This cell names it :article/fetch, so it does not replace the earlier cell's
:article/load, and it uses the stubs that cell installed:
;; cf. examples/real-apps/realworld_http/comments.cljs
;; Alternative to the separate :article/load and reply handlers.
(require '[re-frame.core :as rf]
'[re-frame.http.managed])
(rf/reg-event :article/fetch
(fn [{:keys [db]} [_ slug reply]]
(if (nil? reply)
{:db (assoc-in db [:article :status] :loading)
:fx [[:rf.http/managed
{:request {:url (str "/api/articles/" slug)}
:request-id :article/fetch
:reply-to [:article/fetch slug]}]]}
(case (:status reply)
:ok {:db (assoc db :article {:status :loaded :data (:value reply)})}
:error {:db (assoc db :article {:status :error :error (:error reply)})}
:cancelled {:db (assoc-in db [:article :status] :idle)}))))
(rf/reg-sub :article/fetched (fn [db _] (:article db)))
(rf/reg-view fetch-view []
[:div
[:button {:on-click #(dispatch [:article/fetch "intro"])} "Fetch intro"]
[:button {:on-click #(dispatch [:article/fetch "missing"])} "Fetch missing"]
[:pre (pr-str @(subscribe [:article/fetched]))]])
[rf/frame-root {:id :app/fetch-demo :fx-overrides {:rf.http/managed :rf.http/managed-test-stub}}
[fetch-view]]
The initial [:article/fetch slug] has no reply. The completion arrives as
[:article/fetch slug reply], and the case picks the branch from its
:status. Test for an absent reply before issuing work; cancellation is a
delivered reply and must not start another request.
Silencing a reply¶
Write :reply-to nil and the whole reply is dropped — fire-and-forget, useful for a telemetry beacon you genuinely don't care to handle. Setting :on-success or :on-failure to nil silences just that one side of the split form. But the framework won't let you accidentally swallow an error: the first time a non-aborted failure is dropped because its reply has no target — :reply-to nil, :on-failure nil, or an :on-success with no :on-failure beside it — a one-shot :rf.warning/failure-swallowed trace fires (dev-only) so the silence is observable rather than invisible. Aborted requests are excluded — a cancelled request that no longer wants its reply is correct silence, not a bug.
Your own request builder¶
The effect and machine forms both accept the same request args map. There is no per-verb helper family; an ordinary function can build that map.
When several call sites share a base URL, default headers or body encoding, put those defaults in an ordinary function:
;; cf. examples/real-apps/realworld_http/http.cljs
(defn request
"Assemble a `:rf.http/managed` args map for our API."
[{:keys [method path body decode] :or {method :get decode :json} :as args}]
(let [req (cond-> {:method method
:url (str "https://api.example.com" path)
:headers {"Accept" "application/json"}}
body (assoc :body body :request-content-type :json))]
(-> args
(dissoc :method :path :body)
(assoc :request req :decode decode))))
(rf/reg-event :comment/create
(fn [_ [_ slug text]]
{:fx [[:rf.http/managed
(request {:method :post
:path (str "/articles/" slug "/comments")
:body {:comment {:body text}}
:on-success [:comment/created]
:on-failure [:comment/create-error]})]]}))
The builder returns an args map, so it composes everywhere the args map is accepted — including the :request producers on resources and mutations, which take a map and would reject a pre-built fx vector.
examples/real-apps/realworld_http/http.cljs ships the full version of this pattern: rh/request sets the base URL, a default Accept header, :decode :json, and JSON body encoding, and every call site in that app goes through it.
A valid 200 can still be a failure: :accept¶
Some APIs return 200 OK with {"article": null} when an article is missing.
Use :accept after decoding to turn that domain result into a failure.
:accept is a function (decoded → {:ok value} | {:failure failure-map}). Return {:ok v} and v becomes the success payload; return {:failure m} and m rides into the failure path as :rf.http/accept-failure, with your map at :detail:
{:fx [[:rf.http/managed
{:request {:url (str "/api/articles/" slug)}
:decode :json
:accept (fn [decoded]
(if-let [article (:article decoded)]
{:ok article}
{:failure {:reason :missing-article
:message "Response had no :article"}}))
:on-success [:article/loaded]
:on-failure [:article/load-error]}]]}
The default :accept is just {:ok decoded} — every 2xx that decodes is a success. Three rules keep an explicit one predictable:
:acceptruns only after a successful 2xx decode. A non-2xx response is sorted by status long before this point, so your:acceptnever has to think about HTTP status. A:decodeschema runs first too: one that requires:articlerejects{"article": null}as a decode failure before:acceptsees it.- An
:acceptthat throws, or returns a malformed shape (nil, a non-map, a map with neither:oknor:failure, or one with both) still dispatches a reply — it can never strand the caller. It classifies as:rf.http/accept-failurewith a framework-supplied:detail,{:rf.http/bad-accept :threw :message …}or{:rf.http/bad-accept :malformed-return :returned …}, and the pre-:acceptvalue rides at:decodedso you can see what it choked on. - An accept-failure is not retryable. Retrying the transport won't change the body — this is a domain decision, not a transport blip. If you need "retry after refreshing X," that's a state machine, not
:accept.
Cancel through an external signal: :abort-signal¶
If the cancel signal you want to honour already lives outside re-frame — a parent widget's lifecycle, a shared AbortController — hand its .signal to the request under :abort-signal. You can supply it together with a :request-id and the framework guarantees exactly one terminal outcome no matter which fires first. (:abort-signal is browser-only — the JVM has no AbortController, so :request-id is the cross-host cancel handle.)
Running on the JVM¶
The six Fetch-passthrough keys (:credentials, :mode, :cache, :referrer, :integrity, and the top-level :abort-signal) are meaningful against the browser Fetch API and have no java.net.http.HttpClient analogue. On the JVM:
- The request still goes out with the option dropped, and one
:rf.http/cljs-only-key-ignored-on-jvmwarning trace fires per occurrence so the degraded path is visible. :redirectis the exception: the JVM honours it.- A relative
:urlfails outright: there is no page to resolve it against, so it becomes a:rf.http/transportfailure naming the url. Use an absolute URL there, or a:beforeinterceptor that prefixes a base. - An explicit
:decode :blob,:array-bufferor:form-datadelivers a byte array rather than the browser object, and emits one:rf.http/binary-decode-degraded-on-jvmwarning trace per request. :meta's:status-textis"": the JVM client exposes no reason phrase.:rf.http/corsnever fires; only the browser emits it.
If a request runs on both hosts — SSR, a shared loader — keep cross-host code off these keys or feature-flag them at the call site.
Malformed headers¶
Gotcha — a malformed header is dropped, not fatal
A header with an empty or control-character name, or a value carrying a raw \r/\n (the response-splitting guard), is rejected by the platform's header builder. On the JVM, so is a name the client reserves for itself, such as Host or Connection. Rather than failing the whole request, the runtime drops just that one pair, emits a redacted :rf.warning/http-header-invalid trace naming the offending header (the value is omitted — it may carry a secret), and sends the request with the remaining valid headers. So a stray newline in one interpolated header value quietly loses that header instead of taking down the call — watch the warning trace if a header you expected isn't arriving.
Timestamps come from a coeffect¶
If a reply handler wants to record when something completed, don't call (js/Date.now) in it — a live clock read won't replay the same way twice. Declare the time as a coeffect and read it as data:
(rf/reg-event :article/loaded
{:rf.cofx/requires [:rf/time-ms]}
(fn [{:keys [db rf/time-ms]} [_ {:keys [value]}]]
{:db (-> db
(assoc-in [:article :data] value)
(assoc-in [:article :loaded-at] time-ms))}))
:rf/time-ms is a recordable coeffect: it's stamped onto the event envelope before the handler runs, so the durable write depends on a recorded value and replays identically. (The full grade distinction is recordable vs ambient coeffects.)