Every Suprnova handler returns a Response, which is an alias for
Result<HttpResponse, HttpResponse>. The Ok arm carries the success
response, the Err arm carries an already-rendered error response, and
the ? operator collapses any error type that has a From into
HttpResponse along the way. This chapter is the practical reference
for building the Ok side - the HttpResponse builders, the
Redirect builder, the cookie API, and the abort_* short-circuits.
For the error story see Error Model and
Error Handling.
HttpResponse builders
HttpResponse is the wire-shaped response type. The constructors set
sensible defaults; the chainable setters override them.
Body constructors
use ;
use json;
pub async
Two streaming constructors exist for long-lived responses:
HttpResponse::sse(stream)- Server-Sent Events. Wraps aStreamofSseEventvalues, sets the four required headers (Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive,X-Accel-Buffering: no), and keeps the connection open until the producing stream ends. See Server-Sent Events.HttpResponse::stream_bytes(stream)- generic chunked response. Takes aStream<Item = Result<Bytes, Infallible>>. The error type isInfallibleby design: every producer in the framework turns its own errors into a terminal stream message before the stream ends, because there is no way to surface a transport-level error to the client mid-response.HttpResponse::event_stream(stream, end)- Laravel'sResponseFactory::eventStream. Wraps aStreamofsse::StreamedEventvalues, framing each asevent: update(or its own name) plus a configurable terminal frame. See Server-Sent Events.HttpResponse::stream_json(stream)- Laravel'sResponseFactory::streamJson. Wraps aStreamof anySerializevalue and flushes it as one incrementally-built JSON array instead of buffering the whole collection first. See Server-Sent Events.
Status, headers, cookies
Every builder returns Self, so chain freely:
use ;
use json;
pub async
| Method | Behavior |
|---|---|
.status(code) |
Set the HTTP status. Codes outside 100..=599 downgrade to 500 at the wire boundary with a warning log. |
.header(name, value) |
Append a header. Duplicates allowed (matches Set-Cookie semantics). |
.replace_header(name, value) |
Drop any prior occurrences and set one. |
.with_headers([(k, v), ...]) |
Append many at once. Accepts any IntoIterator<Item = (K, V)>. |
.without_header(name) |
Remove every occurrence (case-insensitive). |
.header_value(name) |
Read back the first-set value. Useful in tests. |
.cookie(Cookie) |
Attach one cookie as Set-Cookie. |
.with_cookies([Cookie, ...]) |
Attach many. |
.without_cookie(name) |
Schedule a deletion (equivalent to Cookie::forget(name)). |
The same chainable setters are available on a Response (the
Result) through the ResponseExt trait, so the macros stay
ergonomic:
use ;
pub async
ResponseExt exposes .status, .header, .with_headers,
.without_header, .cookie, .with_cookies, and .without_cookie.
Wire-boundary validation
HttpResponse::into_hyper runs two safety filters before handing the
response to hyper:
- Status range. Anything outside
100..=599downgrades to 500 with atracing::warn!. This catchesAppError::status(700)typos at the boundary instead of letting non-conformant codes reach the wire. - Header CRLF injection. Every header name and value is validated
via hyper's own
HeaderName::try_from/HeaderValue::try_from. Any rejected header is dropped with a warn log and the response is built without it. Attacker-controlled values that get reflected into a header (CORS allow-headers,X-Forwarded-*, custom debug headers) cannot split the response.
Both filters are silent in the success path - you only see them in logs when something tried to slip through.
Response macros
Two Response-shaped macros exist for the common cases:
use ;
pub async
pub async
Both expand to Ok(HttpResponse::...). Chain ResponseExt setters on
either to adjust status, headers, or cookies.
Cookies
Cookie::new(name, value) produces a cookie with secure defaults -
HttpOnly, Secure, SameSite=Lax, Path=/. Override per cookie:
use Cookie;
use Duration;
let session = new
.http_only
.secure
.same_site
.path
.domain
.max_age
.partitioned;
Four convenience constructors cover common patterns:
Cookie::forget(name)- empty value,Max-Age=0, path/, no domain. Use this on logout to instruct the browser to drop the cookie.Cookie::forget_with(name, path, domain)- the scoped form. A browser only drops a cookie when the deletion cookie'sPathandDomainmatch the ones it was set with, so a cookie set withPath=/adminorDomain=.example.comsurvives a plainforget. PassNonefor either argument to keep the default.Cookie::forever(name, value)- five-yearMax-Age.Cookie::encrypted(name, plaintext)- writes AES-256-GCM ciphertext whose AAD is bound to the cookie's logical name. Read it withCookie::read_encrypted_for(name, wire)using the same name. The value opens under that name and in no other way: the value of another cookie does not open, andread_encrypted_forreturns an error for it. RequiresAPP_KEYto be set at boot. See Encryption.
Removing several cookies at once - the usual logout shape - is
without_cookies, available on HttpResponse, on Response through
ResponseExt, and on both redirect builders:
use ;
let _ = text.without_cookies;
let _: Response = to
.without_cookies
.into;
On a redirect the deletions ride the 302 itself, not the destination, so
the browser has already dropped them by the time it follows the
Location.
Header serialization percent-encodes every byte that isn't a valid cookie-octet per RFC 6265, including all control characters. CRLF in a cookie name or value gets encoded, not propagated - header injection through cookies is closed at the serializer.
Queueing a cookie for later
Sometimes code that isn't building the response still needs to set a
cookie - a listener reacting to an event, a piece of middleware that
runs ahead of the handler, an App::bind service with no HttpResponse
in scope. Cookie::queue is Laravel's Cookie::queue(): it stashes the
cookie in a per-request jar that SessionMiddleware drains onto the
outgoing response, right after the session cookie.
use Cookie;
queue;
// Look up what's queued.
let queued = queued;
// Remove it before the response goes out.
unqueue;
// Queue a deletion instead of a value - composes with `forget_with`.
expire;
The jar is task-local and freshly empty for every request - nothing
queued on one request is visible on the next, and a value queued but
never drained (no SessionMiddleware in the route's chain) is dropped
rather than panicking. Queued cookies attach to whatever the handler
returns, including a redirect: a handler that queues a cookie and then
returns Redirect::to(...) still carries the Set-Cookie header on
the 3xx response. They also attach to a 500 that SessionMiddleware
builds itself for an internal failure partway through the request - an
existing session that can't be read, a session write that fails, or
session-cookie encryption failing - because a queued cookie can already
represent a side effect committed elsewhere (a remember-me token row
already written, for instance), so the response reporting the failure
still carries it. They do not survive a panic - SessionMiddleware's
draining code runs after the handler returns normally, and a caught
panic is converted to a 500 outside the whole middleware chain, the
same point where Laravel's own queued cookies are lost to an uncaught
exception.
Why Suprnova diverges
Laravel's CookieJar keys the queue by name and path, so two cookies
with the same name at different paths can be queued independently.
Suprnova keys the jar by name only: queuing a second cookie under a
name already queued replaces the first rather than adding a second
Set-Cookie line for it. That covers the common case - one call site
owns a given cookie name - without the extra path-keyed lookup
Laravel's version needs.
Redirects
Redirect covers the full Laravel redirector surface. Every variant
implements From<Redirect> for Response, so the idiomatic form is
Redirect::...().into().
Targets
use ;
// Explicit URL or path
let _ = to;
// Same thing, slightly shorter free function
let _ = redirect_to;
// Named route (returns RedirectRouteBuilder)
let _ = route.with;
// Explicit external URL - same as `to`, but the name signals
// "this is going off-site" for open-redirect audits
let _ = away;
// Refresh the page (reads previous URL from the session; falls back
// to "/" if no session scope is active)
let _ = refresh;
// Same, but taking an explicit Request when no scope is active
// let _ = Redirect::refresh_for(&request);
// Session previous_url, with fallback when no session is in scope
let _ = back;
// Session-stored intended URL, consumed on read, with fallback
let _ = intended;
// Guest redirect: stashes the current request URL as "intended" and
// sends the user to a login page
// let _ = Redirect::guest(&request, "/login");
Redirect::back, Redirect::intended, Redirect::guest, and
Redirect::refresh all integrate with the session. Without a session
scope they fall through to their defaults silently - handy for
partial test setups. See Session.
Redirect::back's target - the session's recorded previous URL - is
never trusted verbatim. The session middleware only records a
root-relative, same-origin URL in the first place (a path starting
with // or /\, or carrying an ASCII control byte anywhere in it, is
never stored), and the same check runs again on every read, so back
can't be steered off-origin either by a request that reaches your app
with an unusual path or by a session cookie written before this guard
existed. See Session for the full rule.
Named-route validation
The redirect! proc-macro validates the route name at compile time
and expands to Redirect::route(name):
use ;
pub async
Status codes
use Redirect;
let _ = to.permanent; // 301
let _ = to.status; // 303, 307, 308, ...
The default is 302.
Flash data
Redirect builders carry their own flash bag. On conversion to a
Response the bag drains into the live session, surviving exactly
one more request:
use Redirect;
let _ = back
.with // single key/value
.with_input
.with_errors
.with_errors_bag;
The receiving page reads these back through session.get(...) (for
with), session.get_old_input(...) (for with_input), and the
bag map drained by session.pull_errors_flash() (for
with_errors / with_errors_bag). The Inertia layer consumes the
errors-flash automatically - every Inertia response's errors prop
is seeded from the session, so Redirect::back().with_errors(...)
surfaces messages on the destination without extra wiring. The
X-Inertia-Error-Bag request header scopes the prop under a named
bag for multi-form pages.
Note that on RedirectRouteBuilder (what Redirect::route and
redirect! return), .with(key, value) sets a route parameter,
not a flash entry - use .flash(key, value) there:
use redirect;
let _ = redirect!
.with // route param
.flash; // session flash
Cookies, headers, fragments
use ;
let _ = route
.with_cookies
.with_headers
.with_fragment // append #invoices
.without_fragment; // OR strip any prior fragment
with_fragment accepts the fragment with or without a leading #.
Calling with_fragment after without_fragment re-attaches one.
Preserve fragment across the redirect
For Inertia apps where the destination should preserve the
originating URL hash, use preserve_fragment:
use Redirect;
let _ = route.preserve_fragment;
On conversion this flashes _inertia.preserve_fragment = true into
the session; the next Inertia response reads the flag and emits
preserveFragment: true in its page object. No session scope - flag
silently dropped.
Signed redirects
Two builders wrap the URL-signing surface for one-shot redirects to named routes (password reset, email verification, download links):
use Redirect;
let r = signed_route?;
let r = temporary_signed_route?;
Both return Result<Redirect, FrameworkError> - ?-propagate the
error since Redirect converts to a Response cleanly. See
URLs for the signing surface.
Storing the intended URL
Redirect::set_intended_url writes the session's intended target
without performing a redirect - typically called from auth middleware
before redirecting to /login, so a later Redirect::intended can
recover the originally-requested URL:
set_intended_url;
Aborting from a handler
Three free functions short-circuit a handler at a given status. They
return Result<(), FrameworkError>; combine with ?:
use ;
pub async
The underlying error is FrameworkError::Domain { message, status_code },
so it renders through the same JSON envelope and 5xx sanitisation rules
as every other error path. Out-of-range status codes are coerced to
500 by the response renderer. See Error Model for
the full conversion contract.
Returning errors directly
Because Response is Result<HttpResponse, HttpResponse>, you can
return an Err arm directly - useful when the response shape is
already a specific JSON body and you want it on the wire as-is:
use ;
use json;
pub async
For anything richer - typed domain errors, validation, observability -
use the Error Model surface (AppError,
FrameworkError, #[domain_error]).
Quick reference
| Need | Use |
|---|---|
| JSON response | HttpResponse::json(v) or json_response!({...}) |
| Text response | HttpResponse::text(s) or text_response!(s) |
| HTML response | HttpResponse::html(s) |
| Raw bytes + content-type | HttpResponse::bytes_body(b, "image/png") |
| Server-Sent Events | HttpResponse::sse(stream) - see SSE |
| Chunked stream | HttpResponse::stream_bytes(stream) |
| Set status | .status(code) |
| Add header | .header(k, v) / .with_headers([...]) |
| Remove header | .without_header(name) |
| Attach cookie | .cookie(c) / .with_cookies([...]) |
| Forget cookie | .without_cookie(name) / .without_cookies([...]) |
| Forget a path/domain-scoped cookie | Cookie::forget_with(name, Some("/admin"), Some("example.com")) |
| Queue a cookie for the next response | Cookie::queue(c) |
| Look up a queued cookie | Cookie::queued(name) |
| Remove a cookie from the queue | Cookie::unqueue(name) |
| Queue a deletion cookie | Cookie::expire(name, path, domain) |
| Simple redirect | Redirect::to(path).into() or redirect_to(path).into() |
| Named-route redirect | redirect!("name").into() or Redirect::route("name") |
| Back redirect | Redirect::back(fallback) |
| Intended redirect | Redirect::intended(default) |
| Guest redirect (stash intended) | Redirect::guest(&req, login) |
| Set intended target | Redirect::set_intended_url(url) |
| External URL | Redirect::away(url) |
| Refresh current page | Redirect::refresh() / Redirect::refresh_for(&req) |
| Signed-route redirect | Redirect::signed_route(name, &[(k, v)])? |
| Route param on redirect | .with("key", "value") |
| Query param on redirect | .query("key", "value") |
| Flash data | .with(key, value) (or .flash on RedirectRouteBuilder) |
| Flash input | .with_input([(k, v), ...]) |
| Flash errors | .with_errors([(k, msg), ...]) |
| Named error bag | .with_errors_bag(bag, [(k, msg)]) |
| Append fragment | .with_fragment("section") |
| Strip fragment | .without_fragment() |
| Preserve fragment (Inertia) | .preserve_fragment() |
| Permanent redirect | .permanent() (301) |
| Custom redirect status | .status(303) |
| Abort early | abort_with(code, msg)?, abort_if(cond, code, msg)?, abort_unless(cond, code, msg)? |
Next
- Error Model -
FrameworkError,AppError,HttpError, and the single conversion that renders every error to anHttpResponse - Error Handling - practical handler patterns for
?,AppError, and custom domain errors - Server-Sent Events - building and consuming
sse(...)responses - URLs - signed URLs, named-route resolution, the
surface behind
Redirect::signed_route - Session - flash data, intended URLs, the bag
Redirect::with/with_input/with_errorswrites into
