[Turbo] RFC: Sending custom request headers (Idempotency-Key) from form submissions
- Dominant language
- Ruby
- Stars
- 870
- Forks
- 138
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 208
Description
## Problem
Some requests want data in a **request header** rather than in params — the motivating case being an idempotency key (`Idempotency-Key`, matching the Stripe/Column convention we already follow outbound).
A plain HTML form submission can't do this. The browser builds the request itself, and markup only influences `enctype` (→ `Content-Type`), `accept-charset`, and `referrerpolicy`. There is no `` equivalent and no `form_with` option for it. Anything a plain form sends has to travel as params, in the URL, or in a cookie.
## Why this comes up
We use idempotency keys heavily on the **outbound** side — `ach_transfer.rb`, `wire.rb`, `increase_check.rb`, `column/account_number.rb`, `reimbursement/payout_holding.rb` all pass `idempotency_key:` to Column/Increase, almost always derived from a stable record ID (`self.id.to_s`).
We have nothing equivalent **inbound**. If we want double-submit protection on user-facing money-movement forms (transfers, reimbursements, check sends), we need a transport for a client-supplied key, and a documented pattern so it's done the same way each time.
## Mechanism: `turbo:before-fetch-request`
We're on `turbo-rails` 2.0.17 / `@hotwired/turbo` 8.0.x. Turbo intercepts form submits and reissues them via `fetch`, which means headers *are* reachable — it's how `X-CSRF-Token`, `Turbo-Frame`, and the turbo-stream `Accept` type get set.
`FormSubmission` builds a `FetchRequest` with the `` element as the request's `target`. `FetchRequest#perform()` then runs three steps in order:
1. `delegate.prepareRequest(this)` — attaches `X-CSRF-Token` for unsafe methods, appends the turbo-stream MIME type to `Accept`.
2. Dispatches `turbo:before-fetch-request` — cancelable, bubbling, `detail: { fetchOptions, url, resume }`, dispatched from the form element.
3. Calls `window.fetch` (via `fetchWithTurboHeaders`, which appends `X-Turbo-Request-Id`).
Step 2 is the hook. `fetchOptions` is passed **by reference**, so a listener mutates it in place; there's no return value. It's the last point at which the request is still mutable.
Two gotchas worth writing down:
- At this point `fetchOptions.headers` is a **plain object**, not a `Headers` instance. `headers.set(...)` throws — assign with bracket notation. It only becomes a real `Headers` inside `fetchWithTurboHeaders`, after listeners have run.
- The event fires for **every** Turbo fetch: Drive visits, frame loads, link prefetches, stream sources. Any listener must be scoped or it will stamp headers onto unrelated GETs.
There's also an async escape hatch: `event.preventDefault()` then call `detail.resume()` later. Not needed for this use case.
## Proposed pattern
Scope it with a Stimulus controller rather than a global `document` listener, so the header is opt-in per form and can't leak onto other requests.
```js
// app/javascript/controllers/idempotency_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { key: String }
addHeader(event) {
event.detail.fetchOptions.headers["Idempotency-Key"] = this.keyValue
}
}
```
```erb
<%= form_with model: @transfer, data: {
controller: "idempotency",
action: "turbo:before-fetch-request->idempotency#addHeader",
idempotency_key_value: SecureRandom.uuid
} do |form| %>
```
Server side: `request.headers["Idempotency-Key"]`.
**The key must be generated at render time, not in the listener.** The listener re-runs on every submission attempt, so a `crypto.randomUUID()` there produces a fresh key per retry and buys nothing. Generating it into the markup means a retry of the same rendered form carries the same key, which is the entire point.
## Open questions
1. **What's the contract when the header is absent?** It silently won't be set on any non-Turbo submit — `data: { turbo: false }`, `target="_blank"`, or JS simply failing to load. Do we hard-fail with a 400, or pass through unprotected? Defaulting to "no protection" seems like the wrong call for money movement, but hard-failing makes the form dependent on JS.
2. **Key scope.** Per-render UUID (protects against double-click and retry-after-timeout, but a page reload mints a new key) vs. deriving from a stable record where one already exists. Our outbound code consistently does the latter; there's an argument for matching it wherever a record exists pre-submit.
3. **Where does deduplication live?** Rails cache with a TTL, or a table with a unique index? The latter survives cache eviction, which matters if the whole point is not double-sending money.
4. **Is a header actually the right call here?** A hidden field is strictly more robust — it survives non-Turbo submits and needs no JS. The arguments for the header are consistency with the Stripe/Column convention and keeping the key out of the strong-params surface. Worth settling explicitly rather than by default, since option (1) is a real cost.
Opening this as an RFC rather than just doing it, mostly because of (1) and (4).
Contributor guide
Research direction
Start by reviewing the named outbound files and the described turbo:before-fetch-request sequence, including the Stimulus controller and form examples. Resolve the four open questions—fallback behavior, key scope, deduplication, and header versus hidden field—before implementation. Done means the project has an agreed, documented approach rather than an unresolved RFC.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, rails, ruby
- Domain
- full-stack, payments
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100