O-17. Convert day8.re-frame/http-fx (:http-xhrio) requests to re-frame2 managed HTTP (:rf.http/managed)¶
Type B (semantic rewrite, ask first). The agent identifies every
:http-xhriocall site, surfaces the proposed:rf.http/managedshape per site, and asks the operator to approve before applying — the rewrite is a domain-level re-thinking of decode / failure / retry semantics, not a structural lift. Mechanical translation handles the common cases (GET / POST with JSON request and response, fixed:on-success/:on-failureevent targets, no retry). Hits that lean on:request-formatfor non-JSON content types, custom:response-formatdecoders, ajax interceptor chains, in-flight abort via the underlyingXhrIohandle, or per-request progress callbacks escalate to a human.Cross-references. Required-rule M-31 catalogues the
day8/re-frame2-httpMaven artefact that ships:rf.http/managed— adopting O-17 means picking up that dep alongside core. Required-rule M-39 names the per-frame request-side interceptor surface that replaces ajax-cljs's middleware chain. Companion opt-in O-16 covers the siblingasync-flow-fxadd-on; codebases adopting both rewrites usually want to run O-17 first (so the orchestrating machine's:spawn-allchildren call the new managed-HTTP fx) and O-16 second.
Summary¶
day8.re-frame/http-fx (repo) is a v1-era add-on lib that ships a single fx — :http-xhrio — wrapping the Google Closure XhrIo transport behind a re-frame reg-fx registration. The fx takes an args map ({:method ... :uri ... :params ... :format ... :response-format ... :on-success ... :on-failure ...}) and dispatches a user-named success / failure event when the request resolves. The lib was the de facto HTTP layer for v1 codebases — XhrIo was the only host-portable Google-Closure-backed option in the pre-Fetch era.
re-frame2 covers the same use-case with :rf.http/managed (per 014-HTTPRequests.md, shipped in the M-31 artefact day8/re-frame2-http): the request envelope is the same shape (:method, :url, :headers, :params, :body), but the response surface is richer — an eight-category closed failure taxonomy (:rf.http/transport, :rf.http/cors, :rf.http/timeout, :rf.http/http-4xx, :rf.http/http-5xx, :rf.http/decode-failure, :rf.http/accept-failure, :rf.http/aborted), schema-driven Malli decode + :accept projection, first-class retry-with-backoff, per-attempt timeouts, abort via :request-id, classification ordering that puts status before decode, and a unified :reply-to addressing mode that lets one event handler both issue the request and branch on the reply envelope's :status when the async result arrives. Trace events (:rf.http/retry-attempt, the per-category failure traces) integrate with the standard trace surface that 10x, Xray, Tool-Pair, and the register-listener! listener API consume.
Acting is forced; the conversion path is the opt-in part¶
day8.re-frame/http-fx :refers the removed re-frame.core/console, so the add-on namespace fails to compile the moment day8/re-frame2 is on the classpath — re-frame2 ships no console symbol and no back-compat shim (pre-alpha posture: superseded, not propped up). It does not keep working against v2, and there is no "drop in re-frame2 and keep the requests you didn't convert" plan — the project does not compile until the add-on is gone. So removal-or-conversion is a forced compile-gate pre-step (surfaced at the post-M-0 compile gate, not deferrable), independent of whether the app has converted its own call sites.
What is opt-in is only the choice of path: the operator picks whether to convert each :http-xhrio site to :rf.http/managed (this rule) or remove the add-on outright — but not whether to act. This is an O-rule because that path choice, and the semantic decode / failure / retry rewrite it entails, need operator judgment: the migration agent does NOT auto-rewrite the transport — every :http-xhrio site is surfaced for operator approval per call site, because the :rf.http/managed failure surface is a re-thinking, not a structural lift. (The surrounding reg-event-fx handlers that returned :http-xhrio migrate to the one public reg-event under EP-0018 / M-73 whichever path is chosen — that mechanical M-73 codemod applies either way; the :http-xhrio effect they returned inside :fx is unchanged.)
The agent SHOULD recommend conversion when the codebase is otherwise adopting re-frame2 idioms — managed-HTTP requests integrate with :fx-overrides test stubbing, the canned-stub fxs (:rf.http/managed-canned-success / :rf.http/managed-canned-failure per M-31a), the per-frame request-side interceptor surface (M-39), schema-driven decode, the transport-vs-semantic retry split, and the eight-category trace surface that 10x and Xray visualise out of the box; ajax-cljs's :on-success / :on-failure dispatch surface is opaque to all of them.
Detection¶
The agent looks for:
- Maven coord
day8/re-frame-http-fxindeps.edn/project.clj/shadow-cljs.edn/bb.edn(any version). (:require [day8.re-frame.http-fx])in any namespace (the require has no public symbols beyond the fx-registration side-effect; the require alone is enough to indicate adoption).:http-xhriokeys inside effect maps returned byreg-event-fxhandlers — the unmistakable fingerprint. The key may appear at the top level (v1 effect-map shape, pre-M-8) or inside:fx(post-M-8 shape:fx [[:http-xhrio {...}]]).- Less commonly:
(rf/reg-fx :http-xhrio ...)registrations in user code that wrapped or overrode the lib's fx (uncommon — most projects use the lib's registration as-is).
Each call site is one request. The agent presents the request, the proposed :rf.http/managed form, and the diff for operator approval before any edit.
:http-xhrio → :rf.http/managed concept mapping¶
The shapes are structurally similar — both take an args map and dispatch a result — but the vocabulary diverges in three places: response decoding (ajax-cljs uses :response-format keywords backed by cljs-ajax decoders; managed-HTTP uses :decode keywords or a Malli schema), failure projection (ajax-cljs hands the user the raw XhrIo response on :on-failure; managed-HTTP categorises into eight :rf.http/* kinds before the user sees the reply), and retry (ajax-cljs has none; managed-HTTP ships transport-level retry as a first-class slot).
:http-xhrio concept |
:rf.http/managed concept |
Notes |
|---|---|---|
:method (:get, :post, :put, ...) |
:request :method (same keyword set) |
One-to-one rename. Default is :get in both. |
:uri |
:request :url |
Name change. Both accept a string; both URL-encode :params. |
:params (query string for GET, body for POST) |
:request :params (always query) + :request :body (request body) |
ajax-cljs overloads :params for both query and body depending on :method; managed-HTTP splits them — :request :params is always the URL query (per 014 §Request envelope), :request :body is the request body. Rewrite GET as :params {...}; rewrite POST/PUT/PATCH as :body {...} (Clojure-coll bodies serialise per :request-content-type, per 014 §Body encoding). |
:headers |
:request :headers |
One-to-one (map of string → string). |
:timeout (ms; library default 0 / unbounded) |
:timeout-ms (per-attempt; default 30000 ms) |
ajax-cljs defaults to unbounded; managed-HTTP defaults to 30000 ms per attempt as a security floor (per 014 §:timeout-ms security defaults). The rewrite leaves the default in place unless the v1 site explicitly set :timeout to a non-default value; opt-outs (:timeout-ms nil / :timeout-ms 0) carry deliberate intent and should be rare. Apps facing untrusted upstreams MUST NOT default-port :timeout 0 to :timeout-ms 0. |
:format (ajax/json-request-format) (the request-body encoder helper) |
:request :request-content-type :json (or :form, :text) |
ajax-cljs takes a fn via (ajax/json-request-format); managed-HTTP takes a keyword sugar. Most v1 sites are one of json-request-format / url-request-format / text-request-format; each lowers to the corresponding keyword. Custom formatters (rare) escalate to a human. |
:response-format (ajax/json-response-format {:keywords? true}) (the response decoder) |
:decode :json (or :text / :blob / :array-buffer / :form-data / a Malli schema / a custom fn) |
ajax-cljs's :keywords? true keywordises JSON object keys; managed-HTTP's :decode :json keywordises unconditionally (there is no :keywords? false opt-out) with the keyword-interning cap of 014 §Keyword-interning cap for DoS resistance. So :decode :json is like-for-like only for v1 sites that set {:keywords? true} — string-key sites escalate (see the String-key JSON trap escalation below). For untrusted-origin JSON, prefer :decode :text and parse explicitly. The canonical upgrade is :decode <MalliSchema> (schema-driven decode + validation); for codebases without Malli schemas yet, :decode :json is the like-for-like rewrite. |
:on-success [:event-id] |
:on-success [:event-id] OR :reply-to [:event-id] |
Two addressing styles, and every request must use one: omitting every reply target fails at fx-call time with :rf.error/http-no-reply-target, because nothing routes a reply back to the originating event implicitly. Split :on-success [...] / :on-failure [...]: the canonical reply envelope is appended as the target event's last arg, matching the v1 dispatch shape. Pick the split form when porting v1 sites — it's the like-for-like rewrite, it avoids the operator having to refactor the handler shape in the same pass, and it is the form Spec 014 recommends by default. Unified :reply-to [...]: one target for both the success and the failure reply, with the envelope likewise appended as its last arg; that one handler branches on (:status reply). The two styles are exclusive: :reply-to beside :on-success or :on-failure is refused with :rf.error/http-bad-reply-target (per 014 §Reply addressing). |
:on-failure [:event-id] (raw XhrIo response handed to handler) |
:on-failure [:event-id] (categorised :rf.http/* failure) |
The dispatch shape is the same; the payload changes shape. v1's :on-failure handler receives the raw XhrIo response ({:status N :status-text "" :failure :error :response ... :headers {...}}); v2's :on-failure handler receives the canonical envelope {:status :error :error {:kind <:rf.http/transport-or-similar> ...kind-tags...} …} (per 014 §Failure categories). The rewrite MUST update the handler body to read the new shape — pulling the failure map from :error and branching on its v2 :kind (:rf.http/transport / :rf.http/http-4xx / :rf.http/http-5xx / :rf.http/timeout / :rf.http/decode-failure / :rf.http/accept-failure / :rf.http/aborted / :rf.http/cors) rather than the v1 :status / :failure slots. |
:on-request [:event-id] (lib's per-request lifecycle hook) |
(drop) | ajax-cljs dispatches :on-request before sending — used for request logging. The equivalent in v2 is a register-listener! listener on :rf.http/request-sent (per 014 §Trace surface) — observer-shaped, not handler-shaped. Cross-cutting concerns should not ride request envelopes. |
:interceptors (cljs-ajax interceptor chain — request transform / response transform middleware) |
(rf/reg-http-interceptor id {:before … :after …}) per M-39 |
ajax-cljs's middleware chain ports to the per-frame reg-http-interceptor surface. Bearer-auth header injection / dev-mode base-URL rewrite / correlation-id stamping lift to :before; response-time telemetry / rate-limit header parsing / Cache-Control inspection / 401 auth-refresh lift to :after. Register once per frame; every :rf.http/managed request from that frame picks up the transform. Per-request :interceptors (rare in v1) lower to bespoke :request :headers / :request :url shaping at the call site. |
| No retry support | :retry {:on #{:rf.http/transport :rf.http/http-5xx :rf.http/timeout} :max-attempts 4 :backoff {:base-ms 250 :factor 2 :max-ms 5000 :jitter true}} |
ajax-cljs has no retry; v1 codebases either implemented retry by hand (re-dispatching the request event from the :on-failure handler) or didn't bother. v2 ships transport-level retry as a first-class slot — function of attempt count + failure category, no body inspection (per 014 §Boundary — transport vs semantic retry). The rewrite SHOULD recommend adding :retry for endpoints that benefit from it (idempotent reads against flaky upstreams); the operator picks the :max-attempts and :backoff shape per endpoint. Hand-rolled retry in :on-failure handlers becomes obsolete — flag those handlers for cleanup in the same pass. |
Custom abort via the XhrIo handle |
:request-id <id> + {:fx [[:rf.http/managed-abort <id>]]} |
ajax-cljs callers reached into the underlying XhrIo handle to cancel — a CLJS-only escape hatch. v2 ships abort as a first-class slot: tag the request with a stable :request-id (keyword / vector / uuid), then have an event handler return the :rf.http/managed-abort fx entry with that id (it is an fx-id, not a dispatchable event). Abort always wins over the reply (per 014 §Abort precedence) — the reply lands as :rf.http/aborted with :reason :user, never as the transport-completion category that arrived at the same time. The rewrite is mechanical when the v1 codebase already had an abort site; flag every reach-into-XhrIo for the cleaner v2 surface. |
:progress-cb (per-XHR upload / download progress) |
(escalate) | ajax-cljs exposed XhrIo's progress events as a callback. Managed-HTTP does NOT ship per-request progress callbacks for v1 — streaming + bidirectional flows are out of scope per 014 §Abstract. Sites that depend on progress events escalate to a human; the migration paths are (a) keep the v1 add-on for those specific endpoints, (b) drop the progress UI if it's nice-to-have, (c) wait for a future streaming-aware spec. |
Before / after — representative GET + JSON decode¶
This is the canonical :http-xhrio shape from a typical v1 app — a GET that fetches an article by slug, decoding JSON, dispatching a load-success or load-error event with the result.
Before — :http-xhrio¶
(ns my-app.articles
(:require [re-frame.core :as rf]
[day8.re-frame.http-fx] ;; registers the :http-xhrio fx
[ajax.core :as ajax]))
(rf/reg-event-fx :article/load
(fn [{:keys [db]} [_ slug]]
{:db (-> db
(assoc-in [:article :status] :loading)
(assoc-in [:article :error] nil))
:http-xhrio {:method :get
:uri (str "/articles/" slug)
:timeout 10000
:response-format (ajax/json-response-format {:keywords? true})
:on-success [:article/load-success]
:on-failure [:article/load-failure]}}))
(rf/reg-event-db :article/load-success
(fn [db [_ response]]
(-> db
(assoc-in [:article :status] :loaded)
(assoc-in [:article :data] response)
(assoc-in [:article :error] nil))))
(rf/reg-event-db :article/load-failure
(fn [db [_ {:keys [status response]}]]
(-> db
(assoc-in [:article :status] :error)
(assoc-in [:article :error] {:status status :detail response}))))
After — :rf.http/managed (like-for-like, explicit targets)¶
(ns my-app.articles
(:require [re-frame.core :as rf]
[re-frame.http.managed])) ;; per M-31 — required so :rf.http/* fxs register
(rf/reg-event :article/load
(fn [{:keys [db]} [_ slug]]
{:db (-> db
(assoc-in [:article :status] :loading)
(assoc-in [:article :error] nil))
:fx [[:rf.http/managed
{:request {:method :get
:url (str "/articles/" slug)}
:decode :json ;; or a Malli schema — see below
:timeout-ms 10000
:on-success [:article/load-success]
:on-failure [:article/load-failure]}]]}))
(rf/reg-event :article/load-success ;; one public event form (EP-0018)
(fn [{:keys [db]} [_ {:keys [value]}]] ;; reply lands as {:status :ok :value v …}
{:db (-> db
(assoc-in [:article :status] :loaded)
(assoc-in [:article :data] value)
(assoc-in [:article :error] nil))}))
(rf/reg-event :article/load-failure
(fn [{:keys [db]} [_ {:keys [error]}]] ;; reply lands as {:status :error :error {:kind <:rf.http/*> …} …}
{:db (-> db
(assoc-in [:article :status] :error)
(assoc-in [:article :error]
(case (:kind error) ;; the classified failure category
:rf.http/http-4xx {:kind :not-found :status (:status error)}
:rf.http/http-5xx {:kind :server-err :status (:status error)}
:rf.http/transport {:kind :network :message (:message error)}
:rf.http/timeout {:kind :timeout :elapsed-ms (:elapsed-ms error)}
:rf.http/decode-failure {:kind :bad-payload :cause (:cause error)}
{:kind :unknown :failure error})))}))
What changed:
- The fx moves from a top-level effect key (
:http-xhrio) into:fx. This is the M-8 reshape — every fx in v2 lives in:fx. (Both shapes are valid in pre-M-8 v1 code; the post-M-8 shape is the v2-canonical form.) - The request envelope splits:
:uri→:request :url,:method→:request :method. Every wire-shape key (:method,:url,:headers,:params,:body) is nested under:request; lifecycle keys (:decode,:accept,:retry,:timeout-ms,:on-success,:on-failure,:request-id,:abort-signal) stay at the top level. :response-format (ajax/json-response-format {:keywords? true})becomes:decode :json. The keywordisation default is the same; the surface vocabulary is keyword-sugar instead of a fn-valued slot.:on-successpayload changes fromresponseto the canonical{:status :ok :value <response> …}envelope. The v1 handler received the decoded body as the last arg; the v2 handler receives the uniform reply envelope. The handler body destructures{:keys [value]}to get the payload.:on-failurepayload changes from{:status :response :failure}to the canonical{:status :error :error {:kind <:rf.http/*> ...kind-tags...} …}envelope. The v1 handler keyed off:status(HTTP status code) and:failure(ajax-cljs's failure enum); the v2 handler pulls the classified failure map out from under:errorand keys off(:kind error), a closed:rf.http/*keyword. The rewrite of the failure body is the substantial part of every per-call-site conversion — the operator decides which categories the app handles distinctly vs lumps into a single "show error UI" branch.- The registrars collapse to
reg-event. The v1reg-event-fx :article/loadand the tworeg-event-dbresult handlers all become the one publicreg-event(EP-0018; M-73) —reg-event-fxis a pure rename, and eachreg-event-dbresult handler gains a{:keys [db]}destructure and a{:db …}return wrap. This is the mechanical M-73 codemod and applies whether or not you adopt managed-HTTP. - The
(:require [re-frame.http.managed])clause is added. Per M-31, the managed-HTTP namespace's load-time registrations must fire before the[:rf.http/managed ...]entry hits the drain; without the require, the:fxrunner raises:rf.error/no-such-fx. (The[day8.re-frame.http-fx]require is dropped.)
After — :rf.http/managed (schema-driven decode, recommended)¶
The like-for-like rewrite above replaces :response-format (ajax/json-response-format) with :decode :json — same JSON parsing, no validation. The canonical v2 upgrade pairs :decode with a Malli schema, so the runtime validates the wire payload and surfaces :rf.http/decode-failure on a malformed-but-2xx response (per 014 §Decoding and 014 §Classification order):
(def ArticleResponse
[:map
[:id :uuid]
[:slug :string]
[:title :string]
[:body :string]
[:author [:map [:id :uuid] [:name :string]]]])
(rf/reg-event :article/load
(fn [{:keys [db]} [_ slug]]
{:db (-> db
(assoc-in [:article :status] :loading)
(assoc-in [:article :error] nil))
:fx [[:rf.http/managed
{:request {:method :get
:url (str "/articles/" slug)}
:decode ArticleResponse ;; schema-driven
:timeout-ms 10000
:on-success [:article/load-success]
:on-failure [:article/load-failure]}]]}))
The schema-driven path is strictly stronger than :decode :json: a 2xx response whose body fails to validate against the schema lands as :rf.http/decode-failure instead of as a hard-to-debug "the field I expected is missing" runtime error deeper in the app. The agent SHOULD propose the schema-driven form whenever the codebase already has Malli schemas in play (the M-x sweep for reg-app-schema / per-event :spec keys signals this); when no schemas exist yet, propose :decode :json as the immediate rewrite and surface "add a :decode schema per endpoint" as a follow-on modernisation.
Mapping notes per :http-xhrio slot¶
:method / :uri / :params / :headers¶
Mechanical rename (:uri → :url, all keys nested under :request). The split of v1's overloaded :params (query for GET, body for POST) into v2's :request :params (always query) + :request :body (always body) is the one judgement the agent makes — read the :method slot to disambiguate, and place the v1 :params map into the v2 slot the method implies.
:format (request body encoder)¶
The v1 fn-valued slot lowers to a v2 keyword sugar on :request :request-content-type:
v1 :format |
v2 :request :request-content-type |
|---|---|
(ajax/json-request-format) |
:json (Clojure body → JSON wire bytes) |
(ajax/url-request-format) |
:form (Clojure body → URL-encoded form body) |
(ajax/text-request-format) |
:text (Clojure body → string body) |
| custom request formatter | escalate — the rewrite needs to know what the formatter does |
The v2 keyword sugar handles the common cases. If the v1 site already passes a Blob / FormData / ArrayBuffer as :body and lets the host set the Content-Type (multipart upload is the typical case), the v2 :request :body accepts the same host types — pass through without setting :request-content-type.
:response-format (response body decoder)¶
Mechanical mapping in the common cases:
v1 :response-format |
v2 :decode |
|---|---|
(ajax/json-response-format {:keywords? true}) |
:decode :json (or a Malli schema — recommended) |
(ajax/json-response-format) (no keywordisation) |
:decode :text + (js/JSON.parse %) in :accept (managed-HTTP keywordises JSON object keys by default; opt out via :decode :text and parse explicitly) |
(ajax/text-response-format) |
:decode :text |
(ajax/raw-response-format) |
:decode :text or :decode :blob (depending on whether the v1 code consumed text or bytes) |
| custom response decoder | escalate — a (fn [response-text headers] decoded) fn-valued :decode slot is supported (per 014 §Custom function), but the rewrite needs to understand what the decoder does to map it faithfully |
Security note: untrusted-origin JSON. ajax-cljs's {:keywords? true} turns every JSON object key into a keyword, so a compromised upstream chooses what the host interns: on the JVM (SSR / webhook receivers) a response carrying N unique keys costs N interns — an allocation and an insert into the process-global keyword table each — and every one of those keywords lives as long as the decoded reply does, which for a reply written into app-db can be the life of the session. On the pinned Clojure (1.12.4) the table reclaims keywords nothing references any more, so the cost is churn and retention bounded by what the application keeps, not a permanent leak; CLJS keeps no global keyword table at all. Managed-HTTP enforces a per-request keyword-interning cap (:rf.http/max-decoded-keys, default 10000) and surfaces :rf.http/decode-failure :reason :too-many-keys on overflow (per 014 §Keyword-interning cap). For endpoints whose response shape is dynamic or attacker-influenceable (partner JSON APIs, webhook receivers, agent-controlled fetches), prefer :decode :text + explicit (get response "key") over string-keyed maps — the rewrite SHOULD surface this for every :http-xhrio call site against an untrusted origin and let the operator confirm.
:on-success / :on-failure payload shape¶
The dispatch shape stays the same ([<event-id> <payload>]), but the payload changes:
:on-successpayload — v1: the decoded response. v2: the canonical{:status :ok :value <decoded-response> …}envelope. The handler destructures{:keys [value]}to get the original payload, or branches on(:status reply)when one handler receives both replies (the unified:reply-toform, per 014 §Unified target — one handler (:reply-to)).:on-failurepayload — v1: the rawXhrIoresponse ({:status :status-text :failure :response :headers}). v2: the canonical{:status :error :error {:kind <:rf.http/*> ...kind-tags...} …}envelope. The handler MUST re-shape itscase/condto pull the failure map from:errorand branch on its closed-set:kindkeyword rather than the v1 HTTP status code or ajax-cljs's:failureenum. The closed:rf.http/*taxonomy is richer (distinguishes transport from CORS from timeout from decode-failure from accept-failure) — handlers that previously lumped all failures into "show error" can opt into per-category UX (retry button on 5xx, network-error banner on transport, etc.) but don't have to.
The rewrite flags every :on-failure handler for review. The body almost always needs touching — the only exception is "swallow silently" handlers ((fn [_ _] ...)), which port unchanged.
:timeout¶
ajax-cljs default is unbounded; managed-HTTP default is 30000 ms per attempt as a security floor (per 014 §:timeout-ms security defaults). The rewrite:
- If the v1 site set
:timeout <ms>explicitly: port to:timeout-ms <ms>. - If the v1 site omitted
:timeout(relying on the unbounded default): port to the v2 default by omitting:timeout-ms. Do NOT port to:timeout-ms nil/:timeout-ms 0(the explicit opt-outs) without operator confirmation — the v2 default exists precisely to defend against slow-loris upstreams. The operator opts out only when they genuinely need an unbounded read (rare; usually streaming or long-poll, both better served by a different fx). - If the v1 site set
:timeout 0(the ajax-cljs idiom for "unbounded"): flag for review — the v2 default of 30s is almost certainly the better choice; only port to:timeout-ms 0if the operator confirms unbounded is required.
:on-request / lifecycle hooks¶
Drop. Per-request lifecycle hooks belong on the trace surface (register-listener! filtered on the relevant :rf.http/* event) rather than as args-map slots — cross-cutting concerns should not ride request envelopes. Per 014 §Trace surface.
:interceptors (cljs-ajax middleware chain)¶
ajax-cljs supported a request-transform / response-transform middleware chain via the :interceptors slot — used for cross-cutting concerns (Bearer-auth header injection, correlation-id stamping, dev-mode base-URL rewrite, response-time telemetry, rate-limit parsing, 401 refresh). The v2 equivalent is the per-frame symmetric interceptor surface introduced by M-39 — per rf2-uheqq each interceptor carries optional :before (request side) and :after (response side):
;; v2 — one registration covers both sides for every outbound :rf.http/managed request from the frame
(rf/reg-http-interceptor
:auth/bearer
{:before (fn [ctx] (assoc-in ctx [:request :headers "Authorization"]
(str "Bearer " @auth-token)))})
;; Response-side too — :before stamps a start mark, :after reads it.
(rf/reg-http-interceptor
:telemetry
{:before (fn [ctx] (assoc ctx ::started (js/Date.now)))
:after (fn [ctx resp] (assoc resp :elapsed-ms (- (js/Date.now) (::started ctx))))})
Register once per frame at boot; every :rf.http/managed request from that frame picks up both sides of the transform. The rewrite is a separate pass — extract every cross-cutting concern from v1 :interceptors chains and re-register as reg-http-interceptor calls, splitting the v1 request/response halves onto the matching :before / :after slots. Per-request :interceptors (used rarely in v1) lower to bespoke :request :headers / :request :url shaping at the call site; no v2 equivalent for "request-scoped middleware."
Hand-rolled retry in :on-failure handlers¶
v1 codebases often implemented retry by hand: the :on-failure handler re-dispatches the request event with an incremented attempt count, gated on a sentinel in the failure payload. Replace with :retry on the v2 request:
;; v2
{:fx [[:rf.http/managed
{:request {:method :get :url "/api/flaky"}
:decode :json
:retry {:on #{:rf.http/transport :rf.http/http-5xx :rf.http/timeout}
:max-attempts 4
:backoff {:base-ms 250 :factor 2 :max-ms 5000 :jitter true}}
:on-success [:flaky/loaded]
:on-failure [:flaky/failed]}]]}
The retry decision is a pure function of attempt count + failure category — body-conditional retry ("the body says 'rate-limited, try again with the new token'") is semantic retry and belongs on a state machine, per 014 §Boundary — transport vs semantic retry. Hand-rolled retry that closes over body content / app state / other in-flight requests escalates — it's an O-16 / state-machine concern, not an O-17 mechanical rewrite.
Abort¶
If the v1 codebase reached into the XhrIo handle to cancel, port to v2's first-class abort surface:
;; v2 — tag the request, then abort via the fx
{:fx [[:rf.http/managed
{:request {:method :get :url "/search" :params {:q query}}
:request-id [:search query] ;; stable id, =-comparable
:decode :json
:on-success [:search/results]
:on-failure [:search/error]}]]}
;; cancel from another event's handler — :rf.http/managed-abort is an fx-id,
;; returned as an :fx entry, not a dispatchable event
{:fx [[:rf.http/managed-abort [:search query]]]}
Abort always wins over the natural-completion reply (per 014 §Abort precedence). For spawned-actor contexts (M-45), abort-on-actor-destroy is automatic; per-app abort sites need the explicit :request-id + :rf.http/managed-abort pair.
Explicit escalation cases — the agent surfaces and stops¶
The migration agent does NOT silently rewrite the following. It presents the call site, the reason for escalation, and waits for operator direction:
-
:progress-cb(per-XHR upload / download progress). Managed-HTTP does not ship per-request progress callbacks for v1. Sites that depend on progress UX (file-upload progress bars, big-download spinners) escalate. Paths: keep the v1 add-on for those specific endpoints; drop the progress UX if it's nice-to-have; wait for a future streaming-aware spec. -
Custom
:format/:response-formatfns (not one of the canonical helpers). Bespoke encoders / decoders need the operator to confirm the equivalent:request-content-type/:decodeshape — usually a Malli schema if the decode is shape-validating; a fn-valued:decodeif the decode is genuinely arbitrary. Either path is supported by managed-HTTP, but the rewrite is not mechanical. -
String-key JSON trap —
:keywords? false, omitted response-format options, or string-key handlers. v2:decode :jsonkeywordises JSON object keys unconditionally — there is no:keywords? falseknob — so:decode :jsonis the like-for-like rewrite only for v1 sites that passed{:keywords? true}. A v1 site that read JSON with string keys — explicit(ajax/json-response-format {:keywords? false}), an omitted:response-formatthat defaulted to string keys, or an:on-successhandler doing(get resp "id")/(get-in resp ["a" "b"])— succeeds at the request layer but breaks downstream: handlers, schema validators, and tests expecting string keys now receive keyword keys (and, for untrusted origins, attacker-controlled keys intern up to the cap). Detect and escalate: grep each:on-success/:on-failurehandler body for string-keyed access on the reply, and check the v1:response-formatfor:keywords? false/ omission. When string keys must be preserved, the v2 path is:decode :text+ an explicit parser into a string-keyed map ((js->clj (js/JSON.parse body))/ Cheshire with no:key-fn) — never:decode :json. The operator confirms keep-string-keys vs adopt-keyword-keys (update the handlers / schemas). -
Hand-rolled retry that closes over body content / app state / other in-flight requests. Transport-vs-semantic retry boundary applies. Lift the call site into a state machine state, give that state a
:spawnof:rf.http/managed, and write the semantic retry as a transition on the failure reply (per 014 §Boundary — transport vs semantic retry). Escalate per call site — the rewrite is an O-16 / state-machine design conversation. -
cljs-ajax
:interceptorschains with response-side transforms. Per rf2-uheqq the v2reg-http-interceptorsurface per M-39 is symmetric —:beforecovers the request side,:aftercovers the response side (with the same ctx flowing through both, enabling request-correlated response handling). Response-side cross-cutting concerns (rate-limit header parsing, response-time telemetry, Cache-Control inspection, 401 auth-refresh) lift to:after. Response-shape normalisation may also use:accept(per-request); cross-cutting logging / telemetry may alternatively useregister-listener!. Escalate per chain so the operator can pick the best surface per concern. -
(rf/reg-fx :http-xhrio ...)user-registrations that wrap or override the lib's fx. Uncommon, but seen in projects with bespoke transport (custom auth-aware client, CSRF token-stamping). The v2 equivalent is either (a)reg-http-interceptorfor cross-cutting request transforms (the canonical path), or (b) a user-registered fx alongside:rf.http/managedfor genuinely-bespoke transport (rare; usually a code smell —reg-http-interceptorcovers most of what bespoke fxs did in v1). Escalate per registration. -
GET requests with a request body (uncommon but legal in HTTP — used for some GraphQL queries when the body is "too big" for the URL). ajax-cljs and managed-HTTP both support this, but the v1 → v2 split between
:params(always query) and:body(always body) makes the v1 GET-with-body site visually look like it should port to:params. The agent flags every GET whose v1 site sets:paramsto a non-map or sets explicit headers indicating a body, and asks the operator to confirm the intent.
Out of scope¶
-
day8.re-frame/http-fxitself does not ship under a new coordinate in re-frame2. There is noday8/re-frame2-http-fxartefact, and the v1 add-on cannot stay: it:refers the removedre-frame.core/console, so it fails to compile on v2 (see §Acting is forced above) — removal-or-conversion is forced, not optional. The handlers that return:http-xhrioare yourreg-event-fxhandlers, andreg-event-fxis removed under EP-0018 (M-73); they migrate to the one publicreg-eventwhichever path you pick. -
Streaming, SSE, WebSocket, long-poll, chunked-transfer responses. Managed-HTTP is single-request / single-reply per 014 §Abstract. Sites that lean on
XhrIo's streaming events (rare in:http-xhriov1 code — usually written against a different lib) escalate to a future streaming-aware spec. -
The agent does not auto-detect "the right
:decodeshape" from the response body. Determining whether an endpoint's response is best decoded as:json/:text/Schemais a design call the operator owns. The agent presents the v1 site and the candidate translation; the operator approves, edits, or skips. -
Migrating to the unified
:reply-toform (one handler that issues the request with:reply-topointed back at its own event id and branches on the appended envelope's(:status reply), per 014 §Unified one-handler form (:reply-to)) is a separate modernisation pass per handler — the like-for-like port to:rf.http/managedkeeps the split:on-success/:on-failureshape. The agent SHOULD surface the:reply-toform as a follow-on for handlers that are otherwise simple two-handler pairs; the operator picks per handler. -
Switching to schema-driven decode (
:decode <MalliSchema>) for endpoints that don't yet have schemas is a separate modernisation pass — adopting Malli schemas is its own scope (O-3 covers the broader schema adoption). The agent SHOULD flag every:decode :jsonsite as a candidate for schema-driven decode; the operator decides per endpoint.
Reporting¶
When the agent applies this rule:
- The migration report lists every
:http-xhriocall site it found, whether the operator approved the rewrite, and the resulting:rf.http/managedshape (decode keyword vs schema; retry policy; abort surface). - If the
day8.re-frame/http-fxdep is no longer referenced (all requests migrated), the agent flags the dep for removal in the same report; the operator confirms before the dep is dropped. Theday8/re-frame2-httpdep is added per M-31; there-frame.http.managedrequire is added per namespace that dispatches the new fx. - Every
:on-failurehandler whose body was rewritten to branch on the v2:rf.http/*taxonomy is listed in the report with file/line — operators should review for UX changes (per-category messaging, retry buttons, network-error banners) the v1 surface did not allow. - Every site that hit the string-key trap (v1
:keywords? false/ omitted response-format / string-key handler) is listed with file/line and the operator's decision (keep string keys via:decode :text+ parse, vs adopt keyword keys + update handlers / schemas) — a request that succeeds with a silently keyword-shifted payload is a latent regression. - Each escalation case from above is listed with file/line, the specific reason it escalated, and the agent's recommended path forward.