Skip to content

Spec 014 — HTTP requests

Status: Drafting. v1-optional capability. Implementations MAY ship :rf.http/managed; when they do, the contract below is fixed. The CLJS reference implementation ships it (see §Implementation status). Builds on the registration grammar in 001-Registration, the dispatch envelope and frame routing in 002-Frames §Routing, the trace-stream contract in 009-Instrumentation, schema integration in 010-Schemas, and the reserved-namespace policy in Conventions.

The minimum claim: if an implementation ships HTTP-request infrastructure, it ships :rf.http/managed per this spec — a first-class HTTP request fx that bakes in decoding, success/failure normalisation, retry-with-backoff, abort, schema-driven decode, and reply-to-origin dispatch. The fx is rich enough that apps overwhelmingly want it; the contract being uniform is what lets pair tools, :fx-overrides, retry policy, error projection, and frame-aware reply addressing compose across implementations without per-app reinvention.

Code samples are in ClojureScript (the CLJS reference). The contract is host-agnostic; the spec calls out per-host divergences (CLJS Fetch / JVM java.net.http.HttpClient) explicitly per row.

:rf.http/managed is a managed external effect — per Managed-Effects, the surface MUST satisfy the eight properties (effect-as-data, framework-owned lifecycle, structured failure taxonomy under :rf.http/*, trace-bus observability, :sensitive? / :large? composition, built-in retry / abort / teardown, in-flight registry, per-frame interceptor scoping).

Abstract

:rf.http/managed is the canonical HTTP fx for re-frame2 implementations that ship one. Take an args map, get a request issued; the fx handles transport, decoding, retry-with-backoff, and dispatching the reply back into the runtime. Co-located request and reply handling is the default — one event handler can branch on (:rf/reply msg) to handle both initial dispatch and async result — but explicit :on-success / :on-failure targets switch to the separate-handler shape when that fits better.

The fx specialises Pattern-AsyncEffect's generic six-step shape (register → return :fx → post work → reply → dispatch → commit), pins the lifecycle slice from Pattern-RemoteData, and inherits the epoch carry from Pattern-StaleDetection. It complements but does not replace the lower-level :http fx — apps that need wire-level control (custom transport, raw bytes, idiosyncratic protocols) keep using :http; apps that want the common case ergonomic use :rf.http/managed.

Streaming and bidirectional communication are deliberately out of scope here — Spec 014 covers the single-request / single-reply shape. WebSocket / SSE / chunked-streaming are handled by sibling specs (Pattern-WebSocket; future :rf.http/streaming).

Implementation status

Spec 014 is an optional capability in the 000-Vision §Capability matrix sense. Implementations MAY:

  • Ship :rf.http/managed per this spec. Then the contract below applies — args map shape, failure categories, reply addressing, retry semantics, abort surface, schema-reflection metadata, and trace events all locked. Pair tools and conformance fixtures key off the canonical surface.
  • Omit it. Applications that need HTTP roll their own fx (or use a third-party library) per Pattern-AsyncEffect's generic shape. The omission is a conformance-set difference, not a defect.

The CLJS reference implementation ships :rf.http/managed, backed by Fetch on the browser and java.net.http.HttpClient on the JVM. Other in-scope JS-cross-compile-language ports (TypeScript, Fable (F#), Scala.js, PureScript, Kotlin/JS, Melange / ReScript / Reason, Squint) decide independently — each typically wraps the host's binding to fetch (browser) and the host's runtime HTTP client on Node. A port that omits :rf.http/managed MUST NOT register the :rf.http/* namespace for any other purpose (it's reserved for this Spec; see Conventions).

If an implementation ships ONLY a subset (e.g., no JVM transport), it claims the relevant capability rows and the conformance corpus exercises only those.

Artefact (CLJS reference). Per rf2-5kpd (the fifth per-feature artefact split per rf2-5vjj Strategy B), the CLJS reference's managed-HTTP surface ships in the separate Maven artefact day8/re-frame2-httpre-frame.http-managed namespace, the four :rf.http/* fxs registered at ns-load time, the in-flight request registry, the Fetch / HttpClient transport adapters, the encode / decode pipeline, the retry-with-backoff machinery, the eight-category :rf.http/* failure taxonomy, and the with-managed-request-stubs test helper. The core artefact (day8/re-frame2) no longer carries any of this; apps that don't issue managed-HTTP requests build an :advanced bundle clean of every :rf.http/* symbol and trace string. See MIGRATION §M-31 for the deps swap.

Role

:rf.http/managed, when an implementation ships it, is framework-provided — the implementation registers the fx; applications use it the way they'd use :dispatch or :db. This is what makes it a Spec rather than a convention: the public contract is locked, :fx-overrides target the same id across applications, pair tools introspect the same envelope, and the same schema language Spec 010 standardises (Malli on the CLJS reference) is consumed by the :decode pipeline universally.

The shape

Single fx-id, single args map. Co-located request and reply handling is the default; the user can opt out by providing explicit :on-success / :on-failure targets.

(rf/reg-event-fx :article/load
  (fn [{:keys [db]} [_ {:keys [slug] :as msg}]]
    (if-let [reply (:rf/reply msg)]
      ;; Reply path — same handler, different branch.
      (case (:kind reply)
        :success
        {:db (-> db
                 (assoc-in [:article :status] :loaded)
                 (assoc-in [:article :data]   (:value reply))
                 (assoc-in [:article :error]  nil))}

        :failure
        {:db (-> db
                 (assoc-in [:article :status] :error)
                 (assoc-in [:article :error]  (:failure reply)))})

      ;; Initial dispatch — issue the managed request.
      {: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
              :accept  (fn [decoded]
                         (if-let [article (:article decoded)]
                           {:ok article}
                           {:failure {:reason :missing-article
                                      :message "Response missing :article"}}))
              :retry   {:on           #{:rf.http/transport :rf.http/http-5xx}
                        :max-attempts 4
                        :backoff      {:base-ms 250
                                       :factor  2
                                       :max-ms  5000
                                       :jitter  true}}}]]})))

When the request resolves, the runtime dispatches [:article/load (assoc msg :rf/reply {:kind :success :value article})] (or :failure shape) back to the same event id. The handler's (if-let [reply ...] ...) branch handles the result.

The args map

Key Required? Type Purpose
:request yes map The request envelope (see §Request envelope).
:decode no spec / fn / :json / :text / :blob / :array-buffer / :form-data / :auto How to parse the response body (see §Decoding). Default: :auto (content-type sniffing).
:accept no fn (decoded → {:ok v} | {:failure m}) Post-decode normalisation; lets a handler treat a structurally-valid 200 as a domain failure. Default: (fn [v] {:ok v}) for 2xx, structural failure otherwise.
:retry no map Retry policy (see §Retry and backoff). Default: no retry.
:timeout-ms no int Wall-clock timeout per attempt. Default: 30000.
:on-success no event vector Where to dispatch on success. Default: back to originating event id with :rf/reply merged.
:on-failure no event vector or nil Where to dispatch on failure. Default: back to originating event id with :rf/reply merged. nil means swallow silently.
:request-id no any =-comparable value Stable id for abort + correlation (see §Aborts). Keywords (:search), strings ("req-42"), vectors ([:articles :load 7]), uuids — anything the runtime can =-compare. The fx stores in-flight requests in a {request-id → request-handle} map; identity is structural.
:abort-signal no external AbortController.signal External abort handle. Mutually exclusive with :request-id-driven internal abort.

Request envelope

The :request map carries the wire shape. Keys are minimal and chosen to be host-portable:

Key Required? Type Notes
:method no :get / :head / :post / :put / :patch / :delete / :options Default: :get.
:url yes string May contain :params-derived query string (see below).
:headers no map of string → string (or string → vector of strings for multi-valued) Headers to send. Names are case-insensitive.
:params no map Query-string params. Encoded URL-safely; merged onto :url. Per Spec 012 §URL-encoding rules.
:body no clj coll / string / FormData / Blob / ArrayBuffer / thunk (fn [] body) The request body. See §Body encoding. A thunk is invoked at request-send time (after backoff delays elapse), so very-large payloads aren't held in memory between dispatch and send and retries can re-invoke for a fresh handle.
:request-content-type no :json / :form / :text / explicit MIME / nil Sugar for setting Content-Type + serialising :body. :json runs pr-str → JSON.stringify (CLJS) / Cheshire (JVM). :form URL-encodes a clj map.
:credentials no :omit / :same-origin / :include Default: :same-origin.
:mode no :cors / :no-cors / :same-origin / :navigate CLJS-only; Fetch passthrough. JVM ignores.
:redirect no :follow / :error / :manual Default: :follow.
:cache no Fetch cache directive CLJS-only passthrough.
:referrer no string CLJS-only passthrough.
:integrity no string (subresource-integrity hash) CLJS-only passthrough.

Body encoding

If :body is a thunk (fn [] body), the fx invokes it just before sending (after :retry :backoff delays elapse). Each retry re-invokes the thunk to obtain a fresh handle — useful when :body is a single-shot stream that can't be replayed. Whatever the thunk returns is then encoded per the rules below.

If :body is a Clojure collection AND :request-content-type is unset, the fx inspects:

  • If :request-content-type :json (or detected JSON acceptance via headers) → JSON.stringify after clj->js with :keywordize-keys-aware shape preservation.
  • If :request-content-type :form → URL-encoded form body, sets Content-Type: application/x-www-form-urlencoded.
  • If :request-content-type :text → coerce to string.
  • Otherwise: pass through (the user is supplying a Blob/FormData/ArrayBuffer).

Multipart upload: pass (js/FormData.) directly as :body (or as the return value of a thunk) and let the runtime not set Content-Type (the platform sets the boundary).

Decoding

The :decode key controls how the response body is parsed.

Decode runs only on success-eligible (2xx) responses. Status classification happens before decode — see §Classification order. On a 4xx or 5xx the body is surfaced raw (as the response text) under the :body tag of :rf.http/http-4xx / :rf.http/http-5xx; the :decode pipeline is skipped entirely. This means a JSON endpoint that returns an HTML 404 from the load balancer surfaces as :rf.http/http-4xx with the HTML at :body, NOT as :rf.http/decode-failure.

Schema-driven (the canonical form)

Pass a Malli schema as :decode:

:decode ArticleResponse

The fx: 1. Reads the response body as text. 2. Parses by content-type (JSON if application/json; declared MIME otherwise). 3. Validates / coerces with Malli's decode against the schema. 4. Hands the decoded value to :accept.

If a 2xx response's body fails to decode (transport-OK, status-OK, but malformed payload), the fx classifies it as :rf.http/decode-failure and routes through the failure path. Decode never runs on a 4xx/5xx — see §Classification order.

Explicit content type

:decode :json     ;; force JSON parsing
:decode :text     ;; raw string
:decode :blob     ;; binary blob
:decode :array-buffer
:decode :form-data

No Malli step. The user gets the raw decoded value in :accept.

Custom function

:decode (fn [response-text headers] decoded)

Full control. Throwing inside this fn (on a 2xx response — it doesn't run on non-2xx) classifies as :rf.http/decode-failure.

:auto (default)

Sniff the response Content-Type header: - application/json*:json. - text/*:text. - otherwise → :blob.

Handles 90% of cases without ceremony. Whenever the runtime falls through to :auto (i.e., the user didn't supply :decode), it emits a single :rf.warning/decode-defaulted trace per request so the choice is visible in tooling and logs:

{:operation :rf.warning/decode-defaulted
 :op-type   :warning
 :tags      {:request-id  <id-or-nil>
             :url         <url>
             :content-type <header-value>
             :resolved-decoder <:json | :text | :blob>}}

The warning is informational, not an error — auto-decode is supported and stable. The trace just lets pair tools and 10x panels surface "this handler is relying on the default" so users can choose to be explicit when they want.

Schema reflection (optional, ergonomic)

Pair tools, generators, and AI-assisted tooling want to know which schemas a handler expects from the wire — without invoking the handler. The user can declare them at registration time via the :rf.http/decode-schemas metadata key:

(rf/reg-event-fx :article/load
  {:doc                    "Load an article."
   :rf.http/decode-schemas [ArticleResponse]}     ;; declared up-front for tooling
  (fn [{:keys [db]} [_ {:keys [slug] :as msg}]]
    (if-let [reply (:rf/reply msg)]
      ...
      {:fx [[:rf.http/managed
             {:request {:url (str "/articles/" slug)}
              :decode  ArticleResponse}]]})))     ;; same schema at the call site

Then (rf/handler-meta :event :article/load) returns a map carrying :rf.http/decode-schemas [ArticleResponse], which pair tools / (rf/handlers :event) enumeration / generators can introspect.

Optional, never enforced. The runtime does NOT cross-check that the call-site :decode matches the declared schemas — the metadata is reflective sugar for tooling, not a runtime contract. A handler that declares one schema and uses another still works. (If you want runtime enforcement, you're really asking for a defmanaged-event-fx macro that DRY's the declaration and the call-site reference; out of v1 scope.)

For handlers that issue multiple :rf.http/managed requests with different schemas, list all of them: :rf.http/decode-schemas [ArticleResponse CommentList Profile].

:accept — domain-failure normalisation

After decoding, the user's :accept fn classifies the decoded value:

:accept (fn [decoded]
          (if-let [article (:article decoded)]
            {:ok article}
            {:failure {:kind :payload :message "Response missing :article"}}))

Returns either: - {:ok value} — success; value is the payload of the success reply. - {:failure failure-map} — domain failure; failure-map is the payload of the failure reply.

The default :accept returns {:ok decoded} for 2xx responses, {:failure {:kind :http-status :status N :body decoded}} otherwise.

Retry and backoff

:rf.http/managed's :retry slot owns transport-level retry only — retries whose decision is a pure function of the failure category and the attempt count. Network errors, 5xx, per-attempt timeouts, CORS rejection: each is a :rf.http/* category the runtime classifies before decode, and the policy is "given attempt N and a category from :on, wait backoff(N) ms, then re-issue the same request." The failure category, the attempt count, and the configured backoff are the only inputs; the response body, the application state, and the outcome of any other request never enter the picture.

This is deliberate. Retry decisions that depend on more than category + attempt are semantic retry — the response body says "rate-limited, try again with the new token", or the application is in a state that gates whether to re-issue, or another in-flight request's outcome decides whether this one should retry. Semantic retry is owned by state machines (Spec 005), not by :rf.http/managed. See §Boundary — transport vs semantic retry below.

: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}}
Key Type Purpose
:on set of category keywords Which failure categories trigger a retry. Drawn from the :rf.http/* set in §Failure categories. Common defaults: #{:rf.http/transport :rf.http/http-5xx :rf.http/timeout}. :rf.http/aborted is never retried regardless of :on.
:max-attempts int Total attempts including the first. 1 = no retry. Default: 1.
:backoff map Exponential backoff config.
:backoff.:base-ms int Initial delay (ms).
:backoff.:factor num Multiplier per attempt.
:backoff.:max-ms int Cap on delay.
:backoff.:jitter bool Add random ±25% jitter to each delay.

Each retry advances the carried epoch (per Pattern-StaleDetection); a stale request (e.g. one whose target route changed mid-retry) is suppressed without dispatching the reply.

Boundary — transport vs semantic retry

The retry-ownership rule, stated as a single test: does the retry decision depend on anything other than the failure category and the attempt count? If no, :rf.http/managed :retry is the right home. If yes, lift the retry into a state machine.

Decision shape Owner How
"After a 503, wait backoff(N) and try again — up to N times." :rf.http/managed :retry Function of attempt count + category. Transport.
"After a network timeout, retry with exponential backoff." :rf.http/managed :retry Function of attempt count + category. Transport.
"After a 401, refresh the token, then retry the original request." State machine Response-conditional; another request must succeed first. Semantic.
"If the response body says {:error \"rate-limited\" :retry-after 5}, wait the body's hinted delay." State machine Body-conditional. Semantic.
"Retry the failed write only if the user is still on the page that issued it." State machine App-state-conditional. Semantic.
"Retry every load-asset call that failed during boot, but not if the user navigated away." State machine App-state-conditional, joined across multiple requests. Semantic.

Why the split. Transport retry is mechanical — every category's retry policy is the same shape, and the runtime can express it as a config map at the call site. Semantic retry is a state transition with side-effecting prerequisites — refreshing a token, checking app state, joining outcomes across requests. Encoding that into :retry would either bloat the slot's vocabulary (predicates over response body, dispatched-effect callbacks per attempt, nested conditions) or hide the control flow inside an opaque blob that doesn't show up in traces. Spec 005's machines already give the substrate for "transition on outcome with guards and entry actions"; semantic retry is just a state-machine transition, and the trace stream sees every decision.

The escape hatch. When you reach for "retry-on body matches X", "retry-after refresh", or "retry-when app-state says go" — stop, lift the call site into a state machine state, give that state an :invoke of :rf.http/managed (per Pattern-AsyncEffect), and write the semantic retry as a transition on the failure reply. The machine handles the conditional logic; :rf.http/managed keeps doing transport retry inside each attempt the machine launches. Both halves compose — a state machine that drives :rf.http/managed requests can still configure transport-level :retry on each of those requests; the machine's semantic retry sits outside the per-request retry loop.

See Pattern-Boot §Worked example — auth-machine and the retry-ownership boundary for a concrete demonstration of both halves working together (the auth flow that motivated the boundary in the first place — 5xx-retry-with-backoff at the transport layer, 401-vs-refresh at the semantic layer).

Retry × :on-failure semantics

Only the final exhausted-retries failure dispatches :on-failure. Intermediate attempts that match :retry :on do NOT dispatch the failure handler — the user sees the success reply if any attempt succeeds, and exactly one failure reply (with :max-attempts reached) if every attempt fails.

For debugging visibility, each intermediate attempt emits a :rf.http/retry-attempt trace event with the attempt number, the failure category, and the planned backoff delay before the next attempt:

{:operation :rf.http/retry-attempt
 :op-type   :info
 :tags      {:request-id   <id-or-nil>
             :url          <url>
             :attempt      <n>           ;; 1-based; the failing attempt
             :max-attempts <max>
             :failure      {:kind <:rf.http/*> ...kind-tags...}
             :next-backoff-ms <ms>}}     ;; nil on final exhaustion

Pair tools and 10x panels surface the per-attempt trace; user code only sees the final outcome through :on-failure (or the default reply-to-origin path).

Classification order

When a response arrives, the runtime classifies the outcome in this fixed order:

  1. Transport / timeout / abort. A network error, per-attempt timeout, or abort short-circuits the rest. Classified as :rf.http/transport, :rf.http/cors, :rf.http/timeout, or :rf.http/aborted. The body never enters the picture.
  2. HTTP status. Once a response lands, status is checked before the body is touched.
  3. 2xx → success-eligible; proceed to decode.
  4. 4xx:rf.http/http-4xx; the raw response text is surfaced at :body. Decode is skipped.
  5. 5xx:rf.http/http-5xx; same shape as :http-4xx. Decode is skipped.
  6. Anything else (a 1xx/3xx the runtime didn't follow) → :rf.http/http-4xx-shaped.
  7. Decode (only on 2xx). The configured :decode runs against the body. A throw / Malli rejection / parser error here classifies as :rf.http/decode-failure.
  8. Accept (only on a successful decode). The configured :accept (or the default) projects the decoded value to {:ok v} or {:failure m}. A {:failure m} here classifies as :rf.http/accept-failure.

The order is status-before-decode by design: a JSON-API endpoint that returns an HTML 404 from a load balancer (or a CORS pre-flight 4xx with a generic HTML body, or a 503 with a Cloudfront error page) classifies as :rf.http/http-4xx / :rf.http/http-5xx with the raw body at :body, not as :rf.http/decode-failure. The HTTP failure category is the load-bearing piece of information for the caller; surfacing decode-failure on a 4xx would hide the real error.

If a caller wants to see the structured error body that an API returns alongside a non-2xx (e.g., {"error": "..."} JSON on a 4xx), the caller decodes the raw :body themselves in the failure-handling branch — the framework hands you the bytes and the status, and you decide what to do with them.

Failure categories (closed set)

Every failure carries a :kind keyword (under the framework-reserved :rf.http/* namespace) plus category-specific tags. :kind is framework-owned; user payloads (from :accept) sit at :detail, never at :kind.

:kind When Tags
:rf.http/transport Network / DNS / connection-refused / connection-reset error before the HTTP transaction completed :message, :cause
:rf.http/cors CORS preflight rejected or response blocked by browser CORS policy. Distinct from :transport because CORS is a configuration error, not a network error. CLJS-only; JVM never emits this. :message, :url
:rf.http/timeout Per-attempt timeout fired :elapsed-ms, :limit-ms
:rf.http/http-4xx Non-2xx 4xx response :status, :status-text, :body (the raw response text — decode is skipped on non-2xx; see §Classification order), :headers
:rf.http/http-5xx Non-2xx 5xx response same as :http-4xx
:rf.http/decode-failure A success-eligible (2xx) response whose body the decode pipeline rejected (schema validation error, JSON syntax error, custom decoder threw). Non-2xx responses never produce :rf.http/decode-failure — they classify by status. :body-text, :cause, :schema-validation-failure?
:rf.http/accept-failure :accept returned {:failure user-map}. The user's failure map sits at :detail; :decoded carries the pre-:accept decoded value for context. :detail (user's verbatim failure map), :decoded
:rf.http/aborted The request was aborted via :request-id or :abort-signal :request-id (if any), :reason (:user on the reply; :request-id-superseded is trace-only — see §:request-id (internal))

The category vocabulary is closed for v1 — additions require a Spec change. The :rf.http/* namespace makes these unambiguous wherever they leak: trace events, error projector, :retry :on sets, epoch records.

Reply payload shape

A failure reply lands as:

{:rf/reply {:kind    :failure
            :failure {:kind <one of :rf.http/*>
                      ;; ...kind-specific tags above...
                      :detail <user-payload-if-:accept-failure>}}}

A success reply lands as:

{:rf/reply {:kind  :success
            :value <decoded-and-accepted-payload>}}

The two outer-:kind values (:success / :failure) discriminate the reply branch; the inner :kind (under :failure) names the failure category. Both :kinds are framework-owned and unqualified — they live inside :rf/reply, where the framework is the sole writer.

Reply addressing

The :on-success / :on-failure keys default to "the originating event id with :rf/reply merged into the original message".

Default (omitted) — co-located handler

{:fx [[:rf.http/managed {:request {...} :decode ...}]]}

The fx captures the originating event-id (from the dispatch envelope's cofx). On reply, dispatches:

[<originating-event-id> (assoc original-msg :rf/reply {:kind :success :value v})]

The handler's body is (if-let [reply (:rf/reply msg)] ...handle... ...request...). One handler, two roles, distinguishable by the :rf/reply sentinel.

Explicit target — separate handler

{:fx [[:rf.http/managed {... :on-success [:article/loaded] :on-failure [:article/load-error]}]]}

The :rf/reply payload is appended as the last argument to the dispatched event vector:

[:article/loaded {:kind :success :value v}]

Both addressing modes carry the same shape so handlers can correlate by inspecting either (:rf/reply msg) (in the merged form) or the appended last-arg (in the explicit form).

Silenced

:on-success nil
:on-failure nil

Fire-and-forget. Useful for telemetry beacons.

Aborts

Two mechanisms:

:request-id (internal)

Pass any =-comparable value as a stable id — keyword, string, vector, uuid:

:request-id :article/load                    ;; keyword
:request-id "req-42"                          ;; string
:request-id [:articles :load slug]            ;; structural
:request-id (random-uuid)                     ;; uuid

A subsequent :rf.http/managed-abort fx with the same id (compared by =) cancels the in-flight request, dispatching :on-failure with :kind :rf.http/aborted:

{:fx [[:rf.http/managed-abort :article/load]]}
{:fx [[:rf.http/managed-abort [:articles :load "hello"]]]}

When a fresh request supersedes a prior one with the same :request-id, the prior request's :on-failure reply is not dispatched — semantically the new request replaces the old one (the debounce-search mental model). The supersede event still emits to the trace bus (:rf.http/aborted with :reason :request-id-superseded); consumers wanting abort telemetry subscribe via register-trace-cb! at :warning or :error severity. A manual :rf.http/managed-abort aborts whichever request currently holds the id and DOES dispatch :on-failure with :reason :user.

:abort-signal (external)

Pass an AbortController.signal directly:

:abort-signal (.-signal my-controller)

The fx threads the signal through to the underlying transport. User owns the controller's lifecycle. CLJS-only (Fetch supports it; XHR fallback ignores).

The two are mutually exclusive — pick one.

Abort on actor destroy

Per Spec 005 §Cancellation cascade — in-flight :rf.http/managed aborts (rf2-wvkn), :rf.http/managed requests issued from inside a spawned state-machine actor are aborted automatically when the actor is destroyed.

The contract

When a :rf.http/managed fx is processed, the runtime captures the originating event vector from the dispatch envelope (the :event value on the fx ctx, per Spec 002 §Routing the dispatch envelope). The first element of that vector is the event-id that dispatched the request — for events fired into a spawned actor's handler, that first element is the spawned actor's address (e.g. :http/post#1).

The fx records the (request-id, actor-id) pair in its in-flight registry alongside the abort handle. When the spawned actor is later destroyed (any of the destroy triggers per Spec 005 §The contract), the runtime invokes the late-bind hook :http/abort-on-actor-destroy with the destroyed actor's address. The hook walks the in-flight registry, identifies every request whose actor-id matches, and aborts each — synthesising a standard :rf.http/aborted failure with :reason :actor-destroyed.

Failure shape

The aborted reply is the same shape as a manual-abort failure:

{:rf/reply {:kind    :failure
            :failure {:kind       :rf.http/aborted
                      :request-id <id-or-nil>
                      :reason     :actor-destroyed
                      :actor-id   <destroyed-spawned-actor-id>}}}

The discriminator from a user-issued abort is :reason:user (manual :rf.http/managed-abort) or :actor-destroyed (this contract). Callers that branch on :reason recover that distinction; callers that don't see one uniform "aborted" outcome. (The third reason value, :request-id-superseded, never lands on a reply dispatch — per §:request-id (internal) supersede suppresses the prior request's reply and emits only to the trace bus.)

The reply lands at the originating handler exactly as any other reply does (per §Reply addressing). For requests issued by a spawned actor whose handler the destroy already unregistered, the dispatch is a no-op — the actor's snapshot is gone and there is no event handler to receive the reply. The trace event still fires; the abort is still observable through instrumentation.

Multiple in-flight requests per actor

A spawned actor may issue multiple :rf.http/managed requests in its lifetime. The actor-destroy hook walks every in-flight request whose actor-id matches and aborts each. There is no fairness or ordering guarantee between the aborts; the trace stream sees one :rf.http/aborted-on-actor-destroy per cancelled request.

Sibling actors are not affected

When actor A is destroyed, only A's in-flight requests are aborted. Actor B's in-flight requests — including under the same :invoke-all if :cancel-on-decision? false and B has not yet been told to stop — are unaffected.

:invoke-all's cancel-on-decision (per Spec 005 §Cancel-on-decision) emits one :rf.machine/destroy per surviving sibling, so each sibling's HTTP cascade independently fires the :http/abort-on-actor-destroy hook against its own actor-id.

Direct dispatches from event handlers — NOT covered

Per the spec 005 cross-feature contract, :rf.http/managed requests dispatched directly from ordinary reg-event-fx handlers — i.e. NOT from inside a spawned actor's event handler — are NOT subject to actor-destroy cancellation. The originating event vector's first element is an ordinary registered event-id, not a spawned-actor address; there is no actor-id to correlate against.

This is deliberate. Cancellation tied to actor lifetime is the right scope: the child actor exists to run until the parent says "we no longer care"; the parent destroying the actor kills its outstanding work. Ordinary event handlers have no analogous lifecycle peg — their work is launched as a side effect and outlives the handler.

Apps that want HTTP requests tied to the lifetime of a state-machine state should issue them from inside a spawned child machine, using :invoke or :invoke-all to bind the child's lifetime to the state's. The :rf.http/managed-abort fx and the user-supplied :request-id remain available for app-level cancellation of direct-dispatched requests (per §:request-id (internal)).

Trace event

:rf.http/aborted-on-actor-destroy (per Spec 009 §Trace events) fires once per cancelled request. :tags carry :request-id, :actor-id, and :url.

Cross-references

Frame awareness

The reply dispatch lands in the same frame the request was issued from. The fx captures :frame from the dispatch envelope's cofx (per Spec 002 §Routing) and threads it through to the reply dispatch's {:frame ...} opt. Multi-frame apps work without extra ceremony.

Middleware

Per rf2-6y3q — apps repeatedly want to apply a transform to every outgoing :rf.http/managed request: attach a Bearer token, stamp a correlation-id, rewrite a base URL in dev. v1 ships a per-frame request-side interceptor chain that sits between the user's args and the transport.

Shape

The interceptor shape matches re-frame2's event-handler interceptor idiom — each interceptor is a map {:id <kw> :before (fn [ctx] ctx')} — so authors reuse what they already know.

(rf/reg-http-interceptor
  {:frame  :rf/default
   :id     :auth-header
   :before (fn [ctx]
             (let [token (-> (rf/get-frame-db (:frame ctx)) :auth :token)]
               (cond-> ctx
                 token (assoc-in [:request :headers "Authorization"]
                                 (str "Bearer " token)))))})

ctx contract

Each :before receives a context map with these keys:

Key Type Notes
:request map The :request envelope per §Request envelope. :before returns a ctx whose :request is the modified envelope.
:args map The full :rf.http/managed args map (:request plus :decode / :accept / :retry / :on-success / ...). Read-only by convention; the only field the runtime threads onto the transport is :request.
:frame keyword The resolved frame id.
:event vector The originating event vector (or [:rf.http/managed] when not threaded).

The fn returns the (possibly-modified) ctx. The runtime threads its :request onto the next interceptor (or onto the transport when the chain is exhausted).

Chain order and frame scope

  • Registration order. The chain runs in the order reg-http-interceptor calls were made on that frame. Re-registering an existing id replaces the slot in place — the position is preserved.
  • Per-frame. An interceptor registered against frame A does NOT fire for a request dispatched from frame B. Multi-frame apps register independent chains per frame; the auth interceptor on the user-app frame doesn't leak into a hypothetical admin-app frame.
  • :before-only in v1. Response-side transforms (the moral equivalent of an :after) are out of scope for v1 — sticking with the request-side keeps the contract small. The :after slot is reserved for future extension; an interceptor map carrying :after registers cleanly today (the runtime ignores the key) and will compose with v2's response-side hook when it lands.

Failure mode

A throw inside any :before classifies as :rf.error/http-interceptor-failed. The runtime:

  1. Emits a :rf.error/http-interceptor-failed trace event with :frame, :interceptor-id, :url, and :cause tags (per 009 §Error event catalogue).
  2. Re-throws the wrapped ex-info, which the re-frame.fx outer catch converts to :rf.error/fx-handler-exception (so :rf.fx/handled does NOT fire).
  3. Does NOT dispatch the request — the transport never sees it.

Pair tools and 10x panels see exactly two traces per interceptor failure: the per-interceptor :rf.error/http-interceptor-failed (which carries :interceptor-id) and the cascade-level :rf.error/fx-handler-exception (which carries :fx-id :rf.http/managed). Apps that want to recover gracefully wrap the throwing logic inside the :before itself — the chain has no recovery cofx.

Clearing

(rf/clear-http-interceptor id) removes the slot on :rf/default; (rf/clear-http-interceptor frame-id id) targets a specific frame. The single-arity is the common case (single-frame apps); the two-arity is unambiguous for multi-frame.

Hot-reload tools that re-evaluate registration call sites get the right behaviour automatically: re-reg-http-interceptor of an existing id replaces the slot in place.

Trace events

:operation :op-type When
:rf.http.interceptor/registered :info A reg-http-interceptor succeeded. Tags: :frame, :id.
:rf.http.interceptor/cleared :info A clear-http-interceptor removed an existing slot (no trace fires for a clear-of-unknown-id). Tags: :frame, :id.
:rf.error/http-interceptor-failed :error A :before threw; see §Failure mode. Tags: :frame, :interceptor-id, :url, :cause.

Example — Bearer auth with a single registration

(rf/reg-http-interceptor
  {:frame  :rf/default
   :id     :app/bearer-auth
   :before (fn [ctx]
             (let [token (-> (rf/get-frame-db (:frame ctx)) :auth :token)]
               (cond-> ctx
                 token (assoc-in [:request :headers "Authorization"]
                                 (str "Bearer " token)))))})

;; All subsequent `:rf.http/managed` requests on `:rf/default` carry the
;; header automatically — no per-call-site threading. The interceptor
;; reads the auth slice on every request, so token rotation is picked
;; up without re-registration.

(rf/reg-event-fx :articles/list
  (fn [_ _]
    {:fx [[:rf.http/managed
           {:request {:url "/articles"}                ;; no auth threading
            :decode  ArticleListResponse}]]}))

Public surface

API Kind Signature
reg-http-interceptor Fn (rf/reg-http-interceptor {:frame ... :id ... :before ...})
clear-http-interceptor Fn (rf/clear-http-interceptor id) / (rf/clear-http-interceptor frame id)

Both are re-exported from re-frame.core. Both ship in day8/re-frame2-http; an app that omits the artefact gets :rf.error/http-artefact-missing from the core re-exports per the standard pattern.

Call-site helpers

The canonical [:rf.http/managed args-map] envelope is correct and complete, but the args map carries 12+ keys and every call site repeats {:request {:method <verb> :url <url>} ...} boilerplate. The re-frame.http namespace ships pure synthesis fns — one per HTTP verb — that build the canonical fx vector from a URL + an optional args map. Result:

;; Without helpers — the canonical form, always supported:
{:fx [[:rf.http/managed {:request {:method :get :url "/api/items"}
                         :on-success [:items/loaded]}]]}

;; With helpers — same envelope, fewer keys at the call site:
{:fx [(rf.http/get "/api/items" {:on-success [:items/loaded]})]}

Surface

(:require [re-frame.http :as rf.http])

(rf.http/get     url)  (rf.http/get     url args)
(rf.http/post    url)  (rf.http/post    url args)
(rf.http/put     url)  (rf.http/put     url args)
(rf.http/delete  url)  (rf.http/delete  url args)
(rf.http/patch   url)  (rf.http/patch   url args)
(rf.http/head    url)  (rf.http/head    url args)
(rf.http/options url)  (rf.http/options url args)

Each helper returns a [:rf.http/managed args-map] vector ready to drop into :fx. The helper pins (:method (:request args-map)) to its verb's keyword and (:url (:request args-map)) to the URL argument; caller-supplied :method / :url under :request are overwritten so the call-site contract reads cleanly.

Helper (:request (:method ...)) pinned to
rf.http/get :get
rf.http/post :post
rf.http/put :put
rf.http/delete :delete
rf.http/patch :patch
rf.http/head :head
rf.http/options :options

Args-map merging

Top-level keys (:decode, :accept, :retry, :timeout-ms, :on-success, :on-failure, :request-id, :abort-signal, etc.) pass through to the args map unchanged. The :request map is itself merged with the helper's {:method <verb> :url url} pair (helper wins on :method and :url; caller supplies :headers, :body, :params, :credentials, etc.).

(rf.http/post "/api/items"
              {:request    {:body new-item :request-content-type :json}
               :on-success [:items/created]
               :on-failure [:items/create-failed]})
;; ↓ expands to ↓
[:rf.http/managed
 {:request    {:method :post :url "/api/items"
               :body   new-item :request-content-type :json}
  :on-success [:items/created]
  :on-failure [:items/create-failed]}]

Naming

get collides with clojure.core/get; the namespace does (:refer-clojure :exclude [get]) internally. Users alias the namespace ([re-frame.http :as rf.http]) and write (rf.http/get ...) — the alias is what makes the bare verb names readable. The other verbs (post, put, delete, patch, head, options) don't collide with clojure.core.

Artefact

Ships in day8/re-frame2-http alongside the :rf.http/managed fx the helpers reference. Loading the helpers and the fx are a single dep decision; an app that omits the http artefact can't call the helpers in the first place (compile-time ns failure) rather than discovering at dispatch time that :rf.http/managed isn't registered.

The helpers are NOT re-exported from re-frame.core — users explicitly (:require [re-frame.http :as rf.http]). Re-exporting under the rf/ segment would lose the rf.http/ namespace prefix that makes the call site read as "an HTTP GET" rather than "some framework get".

Examples

A — Simplest possible (sugar all the way down)

(rf/reg-event-fx :ping
  (fn [{:keys [db]} [_ msg]]
    (if-let [reply (:rf/reply msg)]
      {:db (assoc db :pinged-at (:elapsed-at reply))}
      {:fx [[:rf.http/managed {:request {:url "/ping"}}]]})))   ;; :method defaults to :get

No decode (default :auto), no accept (default success-on-2xx), no retry, default reply addressing. Two-line fx.

B — Schema-driven with retry

(rf/reg-event-fx :articles/list
  (fn [{:keys [db]} [_ {:keys [page] :as msg}]]
    (if-let [reply (:rf/reply msg)]
      (case (:kind reply)
        :success {:db (assoc-in db [:articles :data] (:value reply))}
        :failure {:db (assoc-in db [:articles :error] (:failure reply))})
      {:fx [[:rf.http/managed
             {:request {:method :get
                        :url    "/articles"
                        :params {:page page :page-size 20}}
              :decode  ArticleListResponse
              :retry   {:on           #{:rf.http/transport :rf.http/http-5xx}
                        :max-attempts 3
                        :backoff      {:base-ms 200 :factor 2 :max-ms 2000 :jitter true}}}]]})))

C — POST with form body and explicit error handler

(rf/reg-event-fx :auth/login
  (fn [{:keys [db]} [_ creds]]
    {:fx [[:rf.http/managed
           {:request {:method :post
                      :url    "/auth/login"
                      :body   creds
                      :request-content-type :json}
            :decode  AuthResponse
            :on-success [:auth/login-success]
            :on-failure [:auth/login-error]}]]}))

The auth flow has separate success/error handlers (often a state machine), so the co-located shape doesn't fit.

D — Multipart upload, no retry, custom decode

{:fx [[:rf.http/managed
       {:request {:method :post
                  :url    "/upload"
                  :body   form-data}
        :decode  (fn [text headers]
                   {:upload-id (re-find #"id=([0-9a-f]+)" text)})
        :timeout-ms 60000}]]}
(rf/reg-event-fx :search/query
  (fn [{:keys [db]} [_ {:keys [q] :as msg}]]
    (if-let [reply (:rf/reply msg)]
      ;; ...handle results...
      {:fx [[:rf.http/managed-abort :search]                 ;; cancel previous
            [:rf.http/managed
             {:request    {:method :get :url "/search" :params {:q q}}
              :request-id :search
              :decode     SearchResponse}]]})))

The :rf.http/managed-abort fx cancels any in-flight :request-id :search, then a fresh request fires.

F — Same flows, with the call-site helpers

(:require [re-frame.http :as rf.http])

;; A — minimal GET, default reply addressing:
(rf/reg-event-fx :ping
  (fn [{:keys [db]} [_ msg]]
    (if-let [reply (:rf/reply msg)]
      {:db (assoc db :pinged-at (:elapsed-at reply))}
      {:fx [(rf.http/get "/ping")]})))                       ;; ← 1 line vs 1 envelope

;; B — schema-driven GET with retry:
(rf/reg-event-fx :articles/list
  (fn [{:keys [db]} [_ {:keys [page] :as msg}]]
    (if-let [reply (:rf/reply msg)]
      ...
      {:fx [(rf.http/get "/articles"
                         {:request {:params {:page page :page-size 20}}
                          :decode  ArticleListResponse
                          :retry   {:on           #{:rf.http/transport :rf.http/http-5xx}
                                    :max-attempts 3
                                    :backoff      {:base-ms 200 :factor 2 :max-ms 2000 :jitter true}}})]})))

;; C — POST with body and explicit reply targets:
(rf/reg-event-fx :auth/login
  (fn [{:keys [db]} [_ creds]]
    {:fx [(rf.http/post "/auth/login"
                        {:request    {:body creds :request-content-type :json}
                         :decode     AuthResponse
                         :on-success [:auth/login-success]
                         :on-failure [:auth/login-error]})]}))

;; E — aborting a stale search:
(rf/reg-event-fx :search/query
  (fn [{:keys [db]} [_ {:keys [q] :as msg}]]
    (if-let [reply (:rf/reply msg)]
      ...
      {:fx [[:rf.http/managed-abort :search]
            (rf.http/get "/search"
                         {:request    {:params {:q q}}
                          :request-id :search
                          :decode     SearchResponse})]})))

The fx vectors the helpers synthesise are exactly the same shape as the hand-written versions in §A–§E above; :fx-overrides, with-managed-request-stubs, the trace stream, and pair tools see no difference. The helpers are call-site sugar over the same canonical envelope.

Testing

:fx-overrides redirects :rf.http/managed to a stub for tests. The framework ships two canonical stub fxs so tests don't have to roll their own:

;; Success stub — dispatches the configured success reply.
(rf/dispatch-sync [:article/load {:slug "hello"}]
                  {:fx-overrides {:rf.http/managed :rf.http/managed-canned-success}})

;; Failure stub — dispatches the configured failure reply.
(rf/dispatch-sync [:article/load {:slug "missing"}]
                  {:fx-overrides {:rf.http/managed :rf.http/managed-canned-failure}})
Stub fx-id Behaviour
:rf.http/managed-canned-success Synthesises a success reply. Args take :value (the payload to put under :rf/reply :value); defaults to a literal {:stubbed true}.
:rf.http/managed-canned-failure Synthesises a failure reply. Args take :kind (one of :rf.http/*; default :rf.http/transport) and :tags (the kind-specific tags map; defaults documented per row of §Failure categories).

The stubs reuse the same dispatch shape the real fx produces so the test handler's reply branch sees the canonical envelope. Same pattern as the existing http-stub idiom (see examples_test.clj and ssr_end_to_end_test.clj for prior art).

For test suites that exercise many requests, a higher-level helper ships:

(rf/with-managed-request-stubs
  {[:get "/articles/hello"] {:reply {:ok hello-article}}
   [:get "/articles/missing"] {:reply {:failure {:kind :rf.http/http-4xx :status 404}}}}
  ...)

The helper inspects each :rf.http/managed invocation's :request :method + :request :url and routes through the configured reply.

In-flight registry test helpers

For test suites that need to inspect or reset the in-flight request registry directly (e.g. fixtures that share state across dispatch-sync calls, or property-based tests that need a clean slate between iterations), three test-time helpers ship in re-frame.http-managed:

Helper Signature Purpose
clear-all-in-flight! (clear-all-in-flight!) → nil Drops both the request-id-keyed and actor-id-keyed in-flight maps. Consumed by re-frame.test-support/reset-runtime-fixture to restore a clean registry between tests; the :http/clear-all-in-flight! hook is published via the late-bind table so test-support can call it without statically requiring the http artefact.
in-flight-snapshot (in-flight-snapshot) → map Reads the current value of the request-id-keyed in-flight map. For tests that need to assert "this request-id is in flight" without poking the atom directly.
actor-in-flight-snapshot (actor-in-flight-snapshot) → map Reads the current value of the actor-id-keyed in-flight map (per §Abort on actor destroy and rf2-wvkn). For tests that need to assert the actor → request-id reverse index.

These are test-only surfaces — not part of the user-facing API for production code paths. Application code SHOULD route through :rf.http/managed and the dispatch-shape replies; the helpers exist so test fixtures can observe and reset registry state without reaching into the namespace's atoms.

Machine-shape wrapper

Per rf2-ijm7:rf.http/managed is also registered as a child-invokable state machine, so a parent machine can :invoke it without writing any glue. The wrapper is additive on top of the fx surface: :fx [[:rf.http/managed args]] continues to work unchanged (§The shape is the canonical user-facing surface); the machine wrapper is a second affordance for callers who are already inside a state-machine envelope and want a child machine they can compose with :invoke, :after, and the cancellation cascade.

The pattern

(rf/reg-machine :app/auth
  {:initial :idle
   :states
   {:idle           {:on {:login :authenticating}}

    :authenticating
    {:invoke {:machine-id :rf.http/managed
              :data       {:request {:method :get :url "/api/me"}
                           :decode  :json}}
     :after  {30000 :timed-out}                ;; wall-clock guard
     :on     {:succeeded :authenticated
              :failed    :login-failed}}

    :authenticated  {}
    :login-failed   {}
    :timed-out      {}}})

While in :authenticating, a child wrapper actor of :rf.http/managed is alive at [:rf/machines :rf.http/managed#N]. It issues the request on entry; on the reply it transitions to its :succeeded / :failed terminal state and dispatches [<parent-id> [:succeeded value]] (or [<parent-id> [:failed failure]]) back to the parent — which the parent's :on map handles as ordinary FSM events.

Wrapper spec

Internally the wrapper machine has:

key value
:initial :requesting
:states three leaves — :requesting, :succeeded, :failed

:requesting listens for three events:

  • :rf.machine/spawned — the synthetic event the runtime dispatches to spawns without a :start (per Spec 005 §Spawning). The wrapper's :fire-request action runs, emitting the underlying :rf.http/managed fx with :on-success / :on-failure pointing back at the wrapper actor's own id (so the reply lands at the wrapper, not at the user's handler).
  • :rf.http/succeeded — fired when the underlying fx succeeds; records the reply payload at :data :rf/result and transitions to :succeeded.
  • :rf.http/failed — fired when the underlying fx fails (any of the eight :rf.http/* failure categories, per §Failure categories); records the reply payload and transitions to :failed.

The terminal states' :entry dispatches [<parent-id> [:succeeded value]] or [<parent-id> [:failed failure]] — where value is the decoded-and-accepted payload (the same (:value (:rf/reply msg)) an ordinary fx reply carries) and failure is the standard failure map (the same (:failure (:rf/reply msg)) shape per §Reply payload shape). The parent's id comes from :rf/parent-id in the wrapper actor's initial :data — stamped by spawn-fx per Spec 005 §Spawning; the wrapper need not be told its parent at spec-write time.

Args carrier

Every key the §The args map surface accepts may be passed through the parent's :invoke :data:

{:invoke {:machine-id :rf.http/managed
          :data {:request    {:method :post :url "/api/sessions" :body {...}}
                 :request-content-type :json
                 :decode     SessionResponse
                 :accept     (fn [v] (if (:session v) {:ok (:session v)}
                                                       {:failure {:reason :no-session}}))
                 :retry      {:on #{:rf.http/transport :rf.http/http-5xx}
                              :max-attempts 4
                              :backoff {:base-ms 250 :factor 2 :max-ms 5000 :jitter true}}
                 :timeout-ms 30000}}
 :on     {:succeeded :authenticated
          :failed    :login-failed}}

The framework-reserved :rf/* keys the wrapper itself uses (:rf/self-id, :rf/parent-id, :rf/invoke-id, :rf/result) are stripped before the underlying fx call, so they never leak into the request envelope.

:on-success / :on-failure are not passed through — the wrapper overrides them to route the reply back to itself. Apps that want explicit reply addressing should keep using the fx form directly; the machine wrapper is for the :invoke-orchestrated case.

Cancellation cascade

Per §Abort on actor destroy (rf2-wvkn), the wrapper actor's in-flight request is automatically aborted when the wrapper is destroyed. The wrapper is destroyed:

In every case, the standard :http/abort-on-actor-destroy late-bind hook fires, the in-flight HTTP aborts with :reason :actor-destroyed, and the :rf.http/aborted-on-actor-destroy trace event lands. The wrapper actor's failure dispatch back to the parent is suppressed because the wrapper's handler is unregistered before the abort's failure reply lands — the parent has already moved on by then, so no notification is needed.

Multiple wrappers per parent

A parent that needs two parallel HTTP requests uses Spec 005 §Spawn-and-join via :invoke-all with :rf.http/managed named as the :machine-id for each child:

{:hydrating
 {:invoke-all
  {:children       [{:id :user  :machine-id :rf.http/managed
                     :data {:request {:url "/api/me"}}}
                    {:id :prefs :machine-id :rf.http/managed
                     :data {:request {:url "/api/prefs"}}}]
   :join             :all
   :on-child-done    :asset/loaded
   :on-child-error   :asset/failed
   :on-all-complete  [:hydrate/done]
   :on-any-failed    [:hydrate/aborted]}}}

Each child gets its own wrapper actor; cancel-on-decision (default true) tears down survivors when the join resolves; per-sibling cancellation cascades fire the :http/abort-on-actor-destroy hook independently per §Sibling actors are not affected.

When to use the fx form vs the machine form

use case use
Event handler issues a one-off request; reply lands at the handler or a sibling fx form: :fx [[:rf.http/managed args]]
Parent state-machine wants the request tied to a specific state's lifetime, with abort-on-state-exit and :after timeout composition machine form: :invoke {:machine-id :rf.http/managed :data {...}}
Parent state-machine wants multiple concurrent requests with a join condition machine form under :invoke-all (per above)

Apps may mix both freely. The two registrations coexist under :rf.http/managed in the registrar (:fx kind for the fx, :event kind for the machine).

Privacy

Per rf2-bma05 (motivated by the rf2-ok47g §Completeness matrix G3 — the sensitive-elision audit). HTTP is the canonical privacy surface in any application: passwords ride request bodies, auth tokens ride request headers, user PII rides response bodies. Without honouring Spec 009 §Privacy's :sensitive? contract on the :rf.http/* trace events, the HTTP cascade is the biggest leakage vector the framework ships.

Spec 014 specifies HTTP-side honouring on top of the Spec 009 contract: every :rf.http/* trace event MUST stamp :sensitive? when the originating handler is sensitive, MUST redact known-sensitive request headers regardless of handler sensitivity, and MUST redact request / response bodies when the request is sensitive. The contract layers as three cooperating pieces.

1. Header denylist (always-on)

A canonical set of HTTP header names is always sensitive — the names themselves declare the value secret regardless of the surrounding handler's :sensitive? flag. Implementations MUST redact (substitute the framework-reserved :rf/redacted sentinel per Spec 009 §with-redacted) the values of these headers in every :rf.http/* trace event that carries a :headers slot. Header-name matching is case-insensitive.

The v1 closed denylist:

Header name Why
Authorization Bearer tokens, Basic auth credentials
Proxy-Authorization Proxy credentials
Cookie Session identifiers
Set-Cookie Session identifiers (response side)
X-API-Key API key in the bearer-key idiom
X-Auth-Token Bearer-token variant
X-Session-Token Session-token variant
X-CSRF-Token CSRF anti-forgery token
X-XSRF-Token CSRF anti-forgery token (XSRF spelling)
Authentication Some SaaS APIs use the non-standard spelling
WWW-Authenticate Challenge response carries scheme + realm details
Proxy-Authenticate Same as WWW-Authenticate at the proxy layer

Apps extend the denylist for app-specific tokens (e.g. X-Honeycomb-Team, X-Stripe-Signature) via:

(rf.http/declare-sensitive-header! "X-Honeycomb-Team")

Names stored lower-cased; matching is case-insensitive. The default denylist is fixed at boot; the app-extended set is mutable and clearable for test ergonomics via (rf.http/clear-sensitive-headers!).

2. Query-param denylist (always-on) (rf2-2p8wr)

A parallel-axis canonical set of HTTP query-string parameter names is always sensitive — the names themselves declare the value secret regardless of the surrounding handler's :sensitive? flag. URLs in :rf.http/* trace events that carry a denylisted query-string parameter have the value redacted inline: ?api_key=SECRET&page=2?api_key=:rf/redacted&page=2. The parameter name and position are preserved so the operator can still see which endpoint was called and which parameters were present, but the secret value is replaced with the framework-reserved sentinel text. Parameter-name matching is case-insensitive.

The v1 closed denylist:

Param name Why
api_key / apikey / api-key API key in URL query — common legacy idiom
access_token / accesstoken Bearer-token idiom carried on the URL
auth / auth_token / authtoken Generic auth-token names
token Generic bearer-token name
key Generic key name (covers ?key=... API-key idioms)
secret Generic secret-name
password / passwd Password in URL — rare but seen on legacy POST-as-GET endpoints
session / session_id / sessionid Session identifier carried on the URL
signature / sig / hmac Signed-URL HMAC / signature value

Apps extend the denylist for app-specific tokens (e.g. shop_token for Shopify, signature variants in webhook receivers) via:

(rf.http/declare-sensitive-query-param! "shop_token")

Names stored lower-cased; matching is case-insensitive. The default denylist is fixed at boot; the app-extended set is mutable and clearable for test ergonomics via (rf.http/clear-sensitive-query-params!).

A query-param denylist hit alone (no per-handler / per-call :sensitive?) stamps :sensitive? true on the resulting trace event — the presence of a denylisted parameter name is itself a signal that the request carries an auth secret, and downstream privacy-honouring consumers should treat the event accordingly. This is the analogue of the header denylist contract: the name is the signal.

3. Per-call / per-request / per-handler :sensitive?

Three OR-reduced sources contribute the request-side :sensitive? flag for a given :rf.http/managed invocation:

  1. Handler-level:sensitive? true on the originating event handler's :rf/registration-metadata map (per Spec 009 §The :sensitive? registration metadata key). The conventional site: the event handler that owns the request. Every :rf.http/managed dispatched from within a :sensitive?-marked handler inherits the flag.

  2. Per-request:sensitive? true under the :request map of the :rf.http/managed args. For requests where the handler itself is not sensitive but this specific call is (e.g. a generic POST handler that becomes sensitive only when posting to /auth/login). Composes with :request-content-type, :body, etc. unchanged.

  3. Per-call:sensitive? true at the top level of the :rf.http/managed args map. Pragmatic sugar for callers that prefer the flag alongside :on-success / :on-failure rather than nested under :request. Semantically identical to per-request.

Any source set to true makes the request sensitive; all sources defaulting to false/absent means not sensitive. The runtime resolves the effective flag once at fx-invocation time and threads it through the attempt-and-retry loop so every :rf.http/* trace event the cascade emits sees the same flag (no per-emit re-resolution).

;; Handler-level (Spec 009 §Privacy — the inherited form):
(rf/reg-event-fx :auth/sign-in
  {:doc        "Verify credentials and start a session."
   :sensitive? true}
  (fn [_ [_ creds]]
    {:fx [[:rf.http/managed
           {:request {:method :post :url "/auth" :body creds}}]]}))

;; Per-request — a non-sensitive handler with one sensitive call:
(rf/reg-event-fx :api/proxy
  (fn [_ [_ {:keys [target body]}]]
    {:fx [[:rf.http/managed
           {:request    {:method :post :url target :body body
                         :sensitive? (= target "/auth/login")}}]]}))

;; Per-call — same effect, top-level:
(rf/reg-event-fx :api/login
  (fn [_ [_ creds]]
    {:fx [[:rf.http/managed
           {:request    {:method :post :url "/auth/login" :body creds}
            :sensitive? true}]]}))

4. Trace-event redaction + stamping rules

For every :rf.http/* trace event the runtime emits (:rf.http/retry-attempt, :rf.http/aborted-on-actor-destroy, the eight :rf.http/* failure categories from §Failure categories, :rf.warning/decode-defaulted), implementations MUST:

  1. Redact denylisted headers in :headers slots regardless of the effective :sensitive? flag.
  2. Redact denylisted query-string parameter values in :url slots regardless of the effective :sensitive? flag (rf2-2p8wr). Param-name + position preserved; the value is replaced inline with the :rf/redacted text token.
  3. Redact body / body-text / decoded / detail slots when the effective :sensitive? is true. Specifically: :body (request and response), :body-text (decode-failure raw text), :decoded (the pre-:accept decoded value carried by :rf.http/accept-failure), and :detail (the user-supplied failure map carried by :rf.http/accept-failure). All slot values become :rf/redacted.
  4. Redact :params (the structured query-string params map on the request side) when the effective :sensitive? is true. The whole :params map value becomes :rf/redacted.
  5. Redact ALL :url query-string param values when the effective :sensitive? is true (broader rule than the always-on denylist) — when the request is sensitive, anything that rides the wire is. Non-denylisted params (e.g. user_id=42) are scrubbed alongside denylisted ones.
  6. Stamp :sensitive? on the trace event per Spec 009 §Trace-event field. The canonical contract is that the flag rides at the top level of the trace envelope (consumers consult (:sensitive? ev) for a one-keyword read). The HTTP layer stamps :sensitive? true on the tags map passed to trace/emit! / trace/emit-error!. A query-param denylist hit alone (no per-handler / per-call :sensitive?) also stamps :sensitive? true — the denylisted name is itself the signal. If the core trace surface implements the rf2-isdwf hoist (Spec 009 §Privacy core-stamping), the flag is moved from tags to top-level by the emit walker; if core does not yet hoist, the flag stays under :tags. Once core lands the hoist universally, the tags-slot becomes redundant but harmless. Absent (NOT false) when not sensitive — per Spec 009 line 1176 "Consumers treat absent as false."

The cascade-wide stamping uses the innermost in-scope handler rule from Spec 009 §Privacy: each handler in a cascade contributes its own :sensitive? reading. A sensitive handler dispatching a non-sensitive child event does NOT transitively widen the flag — the HTTP fx fired inside the child handler's scope reflects the child's flag. The OR-reduce-by-cascade rollup is the consumer's responsibility (group by :dispatch-id).

Composition

Surface Behaviour
× :large? (Spec 009 §Size elision) A trace-event slot that is BOTH sensitive AND large drops (no :rf.size/large-elided marker — the marker would leak :path / :bytes / :digest). Sensitive wins per Spec 009's unified rf/elide-wire-value walker.
× with-redacted (Spec 009 §Privacy) with-redacted operates on event-vector slots; the HTTP redactor operates on :rf.http/* trace-event slots. Both compose additively — a handler that uses both gets event-vector redaction AND HTTP trace redaction.
× Spec 014 §Middleware Request-side interceptors run before the privacy machinery reads :sensitive? (the interceptor chain may itself attach an Authorization header). Headers added by interceptors are subject to the same denylist.
× Spec 014 §Failure categories Every category that carries body-side payload (:rf.http/http-4xx, :rf.http/http-5xx, :rf.http/decode-failure, :rf.http/accept-failure) gets the redaction treatment when sensitive. :rf.http/aborted carries no body so no body redaction; headers (the denylist) still apply.
× Spec 005 actor-destroy abort The in-flight handle propagates the effective :sensitive? flag, so the :rf.http/aborted-on-actor-destroy emit (issued from the registry namespace, distant from the originating fx ctx) still stamps correctly.
× WebSockets (future) When :rf.ws/* (per Pattern-WebSocket) lands, it inherits the same denylist + per-handler / per-call :sensitive? machinery; the per-message frame-stamping rule is its own affair, but the request-side concerns are shared.

Production elision

The HTTP privacy machinery rides the trace surface and elides with it:

  • The redact / stamp helpers all gate on interop/debug-enabled? at their call sites (the same gate as trace/emit! and trace/emit-error!). In :advanced + goog.DEBUG=false builds Closure DCE removes the trace emits AND the redaction step that prepares them.
  • The header denylist atom itself ships in production (it's read by declare-sensitive-header!). The walker only runs against it when a trace emit fires, so production builds that elide the trace surface incur no runtime cost.
  • The :sensitive? registration-metadata key survives production builds per Spec 009 §Privacy §Production-elision — (rf/handler-meta :event id) reports the flag in dev and production alike for diagnostic-dump tooling that consults the registrar without depending on the trace surface.

Cross-references

What Spec 014 does NOT cover

Adjacent surfaces that are first-class re-frame2 commitments but live in their own specs:

  • Streaming responses (chunked HTTP, server-sent events). Different shape — per-chunk events, not single reply. Future :rf.http/streaming fx; sibling spec.
  • WebSocket — bidirectional. Lives in Pattern-WebSocket; state-machine-shaped.
  • GraphQL-specific batching / persisted queries. Layer on top — :rf.http/managed hands you the decoded response, your application wraps for batching.
  • HTTP/2 server push. Not a re-frame2 concern; the platform handles it transparently.
  • Response-side interceptors (:after). v1's middleware contract is request-side only (§Middleware, rf2-6y3q). Apps that want to project / log / retry on response paths use :accept (domain-failure normalisation) and the trace stream; a future :after slot composes additively when it lands.

Open questions

Response-side middleware composition

Per §Middleware (rf2-6y3q) v1 ships request-side middleware only. A response-side :after slot — composing additively with :accept and :before — would let apps project / log / retry on response paths without per-event boilerplate. Deferred until the request-side surface settles in practice and the composition order with :accept is decided.

App-extensible query-param denylist

Per §2. Query-param denylist (always-on) (rf2-2p8wr) the always-on query-string denylist is a fixed framework-owned set. An extensible registration surface (rf.http/declare-sensitive-query-param! parallel to the header denylist) is a natural addition — deferred until a real app surfaces a query-param-auth pattern outside the default set.

Streaming responses (:rf.http/streaming)

Per §What Spec 014 does NOT cover streaming responses (chunked HTTP, server-sent events) ship in a sibling spec. The per-chunk event model is a different shape from the single-reply :rf.http/managed contract and needs its own envelope; the contract here remains the request → single-reply shape.

Pluggable backoff strategy

Per §Retry and backoff v1 ships a fixed exponential-with-jitter backoff. Pluggable backoff (per-call strategy fn, registered named strategies, host-customisable defaults) is an additive surface — deferred until apps surface a real need that the default doesn't cover.

Resolved decisions

:rf.http/managed is the canonical framework-provided fx

Per §Implementation status :rf.http/managed is the locked v1 surface: args-map shape, failure categories, reply addressing, retry semantics, abort surface, schema-reflection metadata, and trace events are all locked across implementations. This was chosen over a "convention" (every app rolls its own HTTP fx) so :fx-overrides target the same id across applications, pair tools introspect the same envelope, Spec 010 schemas plug into the same decode pipeline, and conformance fixtures key off the canonical surface.

Failure categories are a closed set

Per §Failure categories (closed set) the failure taxonomy under :rf.http/* (:transport, :cors, :timeout, :http-4xx, :http-5xx, :decode-failure, :accept-failure, :aborted) is closed for v1. Additions require a Spec change. Apps that want domain-level discrimination layer :accept (per §:accept — domain-failure normalisation) — they don't extend the framework's failure taxonomy. This keeps the :rf.http/* trace vocabulary decidable for tools and the Spec 009 §Error event catalogue finite.

:rf.http/cors is CLJS-only

Per §Failure categories (closed set) the :rf.http/cors row is CLJS-only — JVM transports never emit it. CORS is a browser-policy concern; the JVM has no cross-origin policy to enforce. The asymmetry is documented so tools that consume the trace stream don't assume the row exists on every host.

Request-side middleware only in v1 (rf2-6y3q)

Per §Middleware (rf2-6y3q) the v1 middleware contract is per-frame request-side only — the interceptor chain sits between the user's args and the transport, not between the transport and the reply. The request-side cases (Bearer token, correlation-id, base-URL rewrite) all surfaced as the high-frequency pattern; response-side composition is deferred to §Open questions. The request-side surface ships first because its shape is settled.

Frame-aware reply dispatch (rf2-wvkn)

Per §Frame awareness every :rf.http/managed reply dispatch inherits the originating frame; replies route to the right frame even when the request was issued from a non-default frame (story variant, per-test fixture, SSR per-request). The frame-capture discipline matches Pattern-AsyncEffect and is universal across the async-effect surface.

Actor-destroy aborts in-flight requests (rf2-wvkn)

Per §Aborts (rf2-wvkn) :rf.http/managed requests issued from inside a spawned state-machine actor are aborted automatically when the actor is destroyed. The actor-id-keyed in-flight map (per §Abort on actor destroy) is the reverse index; actor-in-flight-snapshot is the test-only inspection helper. This was chosen over "orphan the request and ignore the reply" because orphaned requests waste transport quota and the reply path's frame-target may no longer exist — both costs grow under retries.

Privacy honoured via :sensitive? on HTTP trace events (rf2-bma05)

Per §Privacy (rf2-bma05) the :rf.http/* trace events honour the Spec 009 §:sensitive? contract: per-call, per-request, and per-handler :sensitive? flags OR-reduce; the framework redacts request/response bodies and a 12-name header denylist (authorization, cookie, set-cookie, etc.). Headers were chosen as the always-on default surface because they carry the highest-value secrets (auth tokens) across the largest fraction of apps. Apps register their own sensitive headers via rf.http/declare-sensitive-header!.

Query-string denylist is always-on (rf2-2p8wr)

Per §2. Query-param denylist (always-on) (rf2-2p8wr) the framework redacts denylisted query-string parameter values in :url slots regardless of the effective :sensitive? flag. Param-name and position are preserved; the value is replaced inline with the :rf/redacted text token. Always-on was chosen over flag-gated because query-string-auth patterns (older REST APIs, webhooks) leak through :rf.warning/decode-defaulted and similar URL-carrying traces even when the dispatching event isn't :sensitive? — the redaction must run unconditionally for the URL slot to be safe.

Stale-suppression piggy-backs on the epoch carry

Per Pattern-StaleDetection and §Reply addressing managed requests inherit the dispatching event's epoch carry; replies that arrive after a newer navigation / actor restart are suppressed at the dispatch site. The same epoch idiom is used by :after timers (per Spec 005 §Epoch-based stale detection) and route nav-tokens (per Spec 012 §Navigation tokens); the recurring pattern is documented in Pattern-StaleDetection.

Cross-references