///|
#derive(Debug)
priv suberror RedirectError {
TooManyRedirects(max~ : Int32, location~ : String)
Downgrade(location~ : String)
BadLocation(location~ : String, reason~ : String)
UnreplayableBody(location~ : String)
}
///|
fn redirect_failure(error : RedirectError) -> @http.TransportError {
match error {
TooManyRedirects(max~, location~) =>
@http.TransportError(
kind=TooManyRedirects(max~),
phase=Head,
detail="redirect limit exceeded: " + location,
)
Downgrade(location~) =>
@http.TransportError(
kind=RedirectRefused,
phase=Head,
detail="refused redirect to http: " + location,
)
BadLocation(location~, reason~) =>
@http.TransportError(
kind=RedirectRefused,
phase=Head,
detail="bad redirect Location " + location.escape() + ": " + reason,
)
UnreplayableBody(location~) =>
@http.TransportError(
kind=Unreplayable,
phase=Head,
detail="request body was already sent: " + location,
)
}
}
///|
/// A followed redirect's metadata, without retaining either request or
/// response bodies. Its response stream is closed before this is reported.
#derive(Eq, Debug)
pub struct RedirectHop {
request : @http.RequestHead
head : @http.ResponseHead
next : @http.RequestHead
}
///|
/// Compares redirect metadata.
pub extend RedirectHop with Eq::{equal, not_equal}
///|
/// Formats redirect metadata.
pub extend RedirectHop with @debug.Debug::{to_repr}
///|
/// Final URL sent by FollowRedirects, including when no hop was followed.
pub let final_url : @http.AttributeKey[String] = @http.AttributeKey::new(
"final_url",
)
///|
/// Followed hops, at most the effective redirect limit.
pub let redirect_history : @http.AttributeKey[Array[RedirectHop]] = @http.AttributeKey::new(
"redirect_history",
)
///|
/// How `FollowRedirects` follows. Build it from `RedirectConfig::default()`
/// with a record update; the field set is frozen.
#derive(Eq, Debug)
pub(all) struct RedirectConfig {
/// Hops followed before `TooManyRedirects(max)`; 0 or less follows none.
/// The `redirect_limit` attribute overrides it per request.
max_redirects : Int32
/// Whether an `https` request may be redirected to `http`.
allow_downgrade : Bool
/// Headers removed, ignoring ASCII case, when a redirect changes the
/// origin (except a same-host upgrade from `http` on port 80 to `https`
/// on port 443). The list is the whole set: replacing it with one that
/// omits a name forwards that header across origins.
credential_headers : Array[String]
}
///|
/// Compares redirect configurations.
pub extend RedirectConfig with Eq::{equal, not_equal}
///|
/// Formats a redirect configuration for diagnostics.
pub extend RedirectConfig with @debug.Debug::{to_repr}
///|
/// Ten hops, no downgrade, and `authorization`, `proxy-authorization` and
/// `cookie` as the credentials removed on an origin change.
///
/// ```mbt check
/// test {
/// let config = {
/// ..@client.RedirectConfig::default(),
/// credential_headers: [
/// "authorization", "proxy-authorization", "cookie", "x-api-key",
/// ],
/// }
/// assert_eq(config.max_redirects, 10)
/// assert_false(config.allow_downgrade)
/// }
/// ```
pub fn RedirectConfig::default() -> RedirectConfig {
{
max_redirects: 10,
allow_downgrade: false,
credential_headers: ["authorization", "proxy-authorization", "cookie"],
}
}
///|
/// Follows 301, 302, 303, 307 and 308 responses. Intermediate bodies close
/// unread; credentials are removed when the origin changes; an HTTPS
/// downgrade is refused unless configured. A 303, or a 301 or 302 to a POST,
/// becomes a GET without the body; any other hop resends the body, so a body
/// that cannot be sent again is refused (`Unreplayable`).
///
/// The wrapper passes `SendOptions` to every hop unchanged, so each hop gets
/// the attempt deadline; a `Client` with a Timer bounds the whole chain.
/// Place `Cookies` inside this wrapper so every hop is processed, and keep
/// retries above it: a 503 on the second hop re-sends the first.
///
/// A transport failure on a later hop is never reported as unsent: the first
/// request got an answer, so it reached the server. Such a failure has phase
/// `Unknown`, and a `Connect` or `Proxy` kind becomes `Network`; the detail
/// keeps the original.
pub struct FollowRedirects {
priv inner : &@http.Backend
priv config : RedirectConfig
priv credentials : Array[String]
priv on_redirect : ((RedirectHop) -> Unit)?
priv mut closed : Bool
}
///|
/// Wraps a backend. `on_redirect` sees each followed hop after its response
/// was closed.
pub fn FollowRedirects::new(
inner : &@http.Backend,
config~ : RedirectConfig = RedirectConfig::default(),
on_redirect? : (RedirectHop) -> Unit,
) -> FollowRedirects {
{
inner,
config: { ..config, credential_headers: config.credential_headers.copy(), },
credentials: config.credential_headers.map(String::to_lower),
on_redirect,
closed: false,
}
}
///|
/// Follows redirects and records the final URL (`final_url`) and the
/// followed hops (`redirect_history`) on the returned response.
pub async fn FollowRedirects::send(
self : FollowRedirects,
request : @http.Request,
options : @http.SendOptions,
) -> @http.Response raise @http.TransportError {
if self.closed {
raise @http.TransportError(
kind=Closed,
phase=Pool,
detail="redirect backend is closed",
)
}
request.validate()
options.validate()
let max = request.attributes
.get(redirect_limit)
.unwrap_or(self.config.max_redirects)
let mut request = request
let history = []
for followed = 0; ; followed = followed + 1 {
let response = if followed == 0 {
self.inner.send(request, options)
} else {
self.inner.send(request, options) catch {
error => raise after_redirect(error, followed)
}
}
errdefer response.close()
let planned = if max > 0 {
plan_redirect(
request.head(),
response.head(),
replayable=request.body.is_replayable(),
followed~,
max_redirects=max,
allow_downgrade=self.config.allow_downgrade,
credential_headers=self.credentials,
) catch {
error => raise redirect_failure(error)
}
} else {
None
}
guard planned is Some(plan) else {
return {
..response,
attributes: response.attributes
.put(final_url, request.url)
.put(redirect_history, history),
}
}
let next = apply_redirect(request, plan)
response.close()
let hop = RedirectHop::{
request: request.head(),
head: response.head(),
next: next.head(),
}
history.push(hop)
if self.on_redirect is Some(callback) {
callback(hop)
}
request = next
}
}
///|
fn apply_redirect(
request : @http.Request,
plan : RedirectPlan,
) -> @http.Request {
{
..request,
meth: plan.head.meth,
url: plan.head.url,
headers: plan.head.headers,
body: if plan.drop_body {
Empty
} else {
request.body
},
}
}
///|
/// Closes the wrapped backend at most once.
pub fn FollowRedirects::close(self : FollowRedirects) -> Unit {
if !self.closed {
self.closed = true
self.inner.close()
}
}
///|
/// Sends through the shared backend interface.
pub impl @http.Backend for FollowRedirects with fn send(self, request, options) {
self.send(request, options)
}
///|
/// Closes through the shared backend interface.
pub impl @http.Backend for FollowRedirects with fn close(self) {
self.close()
}
///|
/// A failure of a hop after the first, which must not read as unsent: an
/// earlier hop got a response, so the original request reached the server
/// and may have been processed, whatever this hop's phase says. The phase
/// becomes `Unknown`, a `Connect` or `Proxy` kind (which imply that nothing
/// was sent) becomes `Network`, and the detail keeps the original kind,
/// phase and detail.
fn after_redirect(
error : @http.TransportError,
followed : Int32,
) -> @http.TransportError {
let @http.TransportError(kind~, ..) = error
let kind : @http.Kind = match kind {
Connect | Proxy(_) => Network
kind => kind
}
@http.TransportError(
kind~,
phase=Unknown,
detail="redirect hop \{followed} failed after an earlier hop was answered: \{error.message()}",
)
}