From d71fea65bc43d6bf95cba3a9bec0add3d1ae5705 Mon Sep 17 00:00:00 2001 From: "Stephen M. Coakley" Date: Sun, 22 Aug 2021 18:50:37 -0500 Subject: [PATCH] Allow use of Expect header to be configured (#340) Add a configuration option for changing how POST requests behave under HTTP/1.1 and the `Expect: 100-continue` request behavior. Allow the user to change the timeout to a different duration or to disable the header entirely. Replaces #311. Fixes #303. --- src/client.rs | 16 ++++- src/config/mod.rs | 151 ++++++++++++++++++++++++++++++++++++++++++ src/config/request.rs | 5 ++ tests/expect.rs | 29 ++++++++ 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 tests/expect.rs diff --git a/src/client.rs b/src/client.rs index d0a27069..ca57d8d3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1063,14 +1063,20 @@ impl HttpClient { easy.signal(false)?; - request + let request_config = request .extensions() .get::() - .unwrap() - .set_opt(&mut easy)?; + .unwrap(); + request_config.set_opt(&mut easy)?; self.inner.client_config.set_opt(&mut easy)?; + // Check if we need to disable the Expect header. + let disable_expect_header = request_config.expect_continue + .as_ref() + .map(|x| x.is_disabled()) + .unwrap_or_default(); + // Set the HTTP method to use. Curl ties in behavior with the request // method, so we need to configure this carefully. #[allow(indirect_structural_match)] @@ -1144,6 +1150,10 @@ impl HttpClient { headers.append(&header_to_curl_string(name, value, title_case))?; } + if disable_expect_header { + headers.append("Expect:")?; + } + easy.http_headers(headers)?; Ok((easy, future)) diff --git a/src/config/mod.rs b/src/config/mod.rs index 941dd28f..50d7a03d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -200,6 +200,73 @@ pub trait Configurable: request::WithRequestConfig { }) } + /// Configure the use of the `Expect` request header when sending request + /// bodies with HTTP/1.1. + /// + /// By default, when sending requests containing a body of large or unknown + /// length over HTTP/1.1, Isahc will send the request headers first without + /// the body and wait for the server to respond with a 100 (Continue) status + /// code, as defined by [RFC 7231, Section + /// 5.1.1](https://datatracker.ietf.org/doc/html/rfc7231#section-5.1.1). + /// This gives the opportunity for the server to reject the response without + /// needing to first transmit the request body over the network, if the body + /// contents are not necessary for the server to determine an appropriate + /// response. + /// + /// For servers that do not support this behavior and instead simply wait + /// for the request body without responding with a 100 (Continue), there is + /// a limited timeout before the response body is sent anyway without + /// confirmation. The default timeout is 1 second, but this can be + /// configured. + /// + /// The `Expect` behavior can also be disabled entirely. + /// + /// This configuration only takes effect when using HTTP/1.1. + /// + /// # Examples + /// + /// ``` + /// use std::time::Duration; + /// use isahc::{ + /// config::ExpectContinue, + /// prelude::*, + /// HttpClient, + /// }; + /// + /// // Use the default behavior (enabled). + /// let client = HttpClient::builder() + /// .expect_continue(ExpectContinue::enabled()) + /// // or equivalently... + /// .expect_continue(ExpectContinue::default()) + /// // or equivalently... + /// .expect_continue(true) + /// .build()?; + /// + /// // Customize the timeout if the server doesn't respond with a 100 + /// // (Continue) status. + /// let client = HttpClient::builder() + /// .expect_continue(ExpectContinue::timeout(Duration::from_millis(200))) + /// // or equivalently... + /// .expect_continue(Duration::from_millis(200)) + /// .build()?; + /// + /// // Disable the Expect header entirely. + /// let client = HttpClient::builder() + /// .expect_continue(ExpectContinue::disabled()) + /// // or equivalently... + /// .expect_continue(false) + /// .build()?; + /// # Ok::<(), isahc::Error>(()) + /// ``` + fn expect_continue(self, expect: T) -> Self + where + T: Into, + { + self.with_config(move |config| { + config.expect_continue = Some(expect.into()); + }) + } + /// Set one or more default HTTP authentication methods to attempt to use /// when authenticating with the server. /// @@ -872,3 +939,87 @@ impl SetOpt for IpVersion { }) } } + +/// Controls the use of the `Expect` request header when sending request bodies +/// with HTTP/1.1. +/// +/// By default, when sending requests containing a body of large or unknown +/// length over HTTP/1.1, Isahc will send the request headers first without the +/// body and wait for the server to respond with a 100 (Continue) status code, +/// as defined by [RFC 7231, Section +/// 5.1.1](https://datatracker.ietf.org/doc/html/rfc7231#section-5.1.1). This +/// gives the opportunity for the server to reject the response without needing +/// to first transmit the request body over the network, if the body contents +/// are not necessary for the server to determine an appropriate response. +/// +/// For servers that do not support this behavior and instead simply wait for +/// the request body without responding with a 100 (Continue), there is a +/// limited timeout before the response body is sent anyway without +/// confirmation. The default timeout is 1 second, but this can be configured. +/// +/// The `Expect` behavior can also be disabled entirely. +/// +/// This configuration only takes effect when using HTTP/1.1. +#[derive(Clone, Debug)] +pub struct ExpectContinue { + timeout: Option, +} + +impl ExpectContinue { + /// Enable the use of `Expect` and wait for a 100 (Continue) response with a + /// default timeout before sending a request body. + pub const fn enabled() -> Self { + Self::timeout(Duration::from_secs(1)) + } + + /// Enable the use of `Expect` and wait for a 100 (Continue) response, up to + /// the given timeout, before sending a request body. + pub const fn timeout(timeout: Duration) -> Self { + Self { + timeout: Some(timeout), + } + } + + /// Disable the use and handling of the `Expect` request header. + pub const fn disabled() -> Self { + Self { + timeout: None, + } + } + + pub(crate) fn is_disabled(&self) -> bool { + self.timeout.is_none() + } +} + +impl Default for ExpectContinue { + fn default() -> Self { + Self::enabled() + } +} + +impl From for ExpectContinue { + fn from(value: bool) -> Self { + if value { + Self::enabled() + } else { + Self::disabled() + } + } +} + +impl From for ExpectContinue { + fn from(value: Duration) -> Self { + Self::timeout(value) + } +} + +impl SetOpt for ExpectContinue { + fn set_opt(&self, easy: &mut Easy2) -> Result<(), curl::Error> { + if let Some(timeout) = self.timeout { + easy.expect_100_timeout(timeout) + } else { + Ok(()) + } + } +} diff --git a/src/config/request.rs b/src/config/request.rs index d29236e3..6e6551f1 100644 --- a/src/config/request.rs +++ b/src/config/request.rs @@ -68,6 +68,7 @@ define_request_config! { low_speed_timeout: Option<(u32, Duration)>, version_negotiation: Option, automatic_decompression: Option, + expect_continue: Option, authentication: Option, credentials: Option, tcp_keepalive: Option, @@ -136,6 +137,10 @@ impl SetOpt for RequestConfig { } } + if let Some(expect_continue) = self.expect_continue.as_ref() { + expect_continue.set_opt(easy)?; + } + if let Some(auth) = self.authentication.as_ref() { auth.set_opt(easy)?; } diff --git a/tests/expect.rs b/tests/expect.rs new file mode 100644 index 00000000..820df70b --- /dev/null +++ b/tests/expect.rs @@ -0,0 +1,29 @@ +use isahc::{Body, Request, prelude::*}; +use testserver::mock; + +#[test] +fn expect_header_is_sent_by_default() { + let m = mock!(); + + let body = Body::from_reader("hello world".as_bytes()); + + isahc::post(m.url(), body).unwrap(); + + m.request().expect_header("expect", "100-continue"); +} + +#[test] +fn expect_header_is_not_sent_when_disabled() { + let m = mock!(); + + let body = Body::from_reader("hello world".as_bytes()); + + Request::post(m.url()) + .expect_continue(false) + .body(body) + .unwrap() + .send() + .unwrap(); + + assert!(m.request().get_header("expect").next().is_none()); +}