Skip to content

Requests always time out when tokio time is paused #2783

@kdkasad

Description

@kdkasad

Issue description

Tokio has a feature that lets you pause/advance time, intended for use in testing code. When time is paused, there is no work to be done on the runtime, and a sleep/timer future is polled, tokio will automatically advance time to the deadline of said sleep/timer. This allows for testing things using sleeps/timers instantly without actually having to wait.

However, requests made with reqwest using .timeout() will always time out when time is paused. Here's an example (repo with proper dependencies in Cargo.toml is here):

use std::time::Duration;

use reqwest::Client;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let client = Client::builder().timeout(Duration::from_secs(60)).build().unwrap();

    tokio::time::pause();
    let result = client.head("http://kasad.com").send().await;
    println!("{result:?}");
}

If we comment out the tokio::time::pause() call, the request completes in under a second and returns Ok(Response { ... }). This is expected.

However, with the tokio::time::pause() call enabled, the request instantly times out and returns Err(reqwest::Error { kind: Request, url: "https://kasad.com/", source: TimedOut }). This is not expected behavior; it should perform the request normally, only timing out after 60 seconds (or never since time is paused; depends on implementation).

Why does it matter?

It's reasonable to want to use paused time to test some code which makes HTTP requests. For example, @Xendergo and I discovered this issue in purduehackers/webring, where we want to re-try an HTTP request after some delay if it fails the first time. To test this without having to run a painfully slow unit test, we use tokio::time::pause() so that the sleep() in between request attempts will be auto-advanced.

Possible cause

Note

I'm not familiar at all with reqwest's codebase. I poked around just enough to find something that looks like it could cause this issue.

Tokio only auto-advances time when there is no other work to do. However, waiting for a response is work, so we should not see an auto-advance happening when making a request.

I believe the reason we do see an auto-advance is because the sleep future for the timeout is polled before the one for the body (this also applies to other things with timeouts, like the connect or reading headers):

if let Poll::Ready(()) = this.timeout.as_mut().poll(cx) {
return Poll::Ready(Some(Err(crate::error::body(crate::error::TimedOut))));
}
Poll::Ready(
ready!(this.inner.poll_frame(cx))
.map(|opt_chunk| opt_chunk.map_err(crate::error::body)),
)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions