From 87324cc96a0a9415aeb6b2c20b1444a4a2a6f3b6 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Thu, 7 May 2026 13:37:50 -0500 Subject: [PATCH] Add basic abortsignal example Showing how to apply timeouts to fetch requests --- Cargo.lock | 8 +++ examples/abort-signal/Cargo.toml | 14 +++++ examples/abort-signal/README.md | 53 +++++++++++++++++++ examples/abort-signal/slow-server.mjs | 37 +++++++++++++ examples/abort-signal/src/lib.rs | 76 +++++++++++++++++++++++++++ examples/abort-signal/wrangler.toml | 6 +++ 6 files changed, 194 insertions(+) create mode 100644 examples/abort-signal/Cargo.toml create mode 100644 examples/abort-signal/README.md create mode 100644 examples/abort-signal/slow-server.mjs create mode 100644 examples/abort-signal/src/lib.rs create mode 100644 examples/abort-signal/wrangler.toml diff --git a/Cargo.lock b/Cargo.lock index da92b199..c804a8cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,14 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "abort-signal-on-workers" +version = "0.1.0" +dependencies = [ + "futures-util", + "worker", +] + [[package]] name = "adler2" version = "2.0.1" diff --git a/examples/abort-signal/Cargo.toml b/examples/abort-signal/Cargo.toml new file mode 100644 index 00000000..9b3ed6f4 --- /dev/null +++ b/examples/abort-signal/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "abort-signal-on-workers" +version = "0.1.0" +edition = "2021" + +[package.metadata.release] +release = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +worker.workspace = true +futures-util = { workspace = true, features = ["async-await"] } diff --git a/examples/abort-signal/README.md b/examples/abort-signal/README.md new file mode 100644 index 00000000..afb91297 --- /dev/null +++ b/examples/abort-signal/README.md @@ -0,0 +1,53 @@ +# AbortSignal example + +Cancel in-flight fetch requests using `AbortController` and `AbortSignal` in a Rust Cloudflare Worker. + +## Routes + +| Route | Description | +|---|---| +| `GET /abort?url=` | Fetches the URL and immediately aborts. Always returns an abort error. | +| `GET /timeout?url=&timeout=` | Fetches the URL with a timeout (default 2000ms). Cancels the request if the server doesn't respond in time. | + +## Local slow server + +A slow Node server is included for testing timeouts locally: + +```sh +node slow-server.mjs # port 3000, 5s delay +node slow-server.mjs 9000 10 # port 9000, 10s delay +``` + +The server has two endpoints: +- `GET /` returns a response after the default delay +- `GET /delay/:ms` returns a response after `:ms` milliseconds + +## Testing + +1. Start the slow server on a port (e.g. 3000): + ```sh + node slow-server.mjs 3000 + ``` + +2. Start the Worker (in the `abort-signal` example directory): + ```sh + npx wrangler dev + ``` + +3. Test immediate abort: + ```sh + curl "http://localhost:8787/abort?url=http://localhost:3000" + # → "Aborted: ..." + ``` + +4. Test timeout (500ms timeout against a 5s delayed server): + ```sh + curl "http://localhost:8787/timeout?url=http://localhost:3000&timeout=500" + # → "Request timed out after 500ms" + ``` + +5. Test timeout where the server responds in time (using `/delay/100` for 100ms): + ```sh + curl "http://localhost:8787/timeout?url=http://localhost:3000/delay/100&timeout=2000" + # → "Got response: {\"delayed_ms\":100,\"message\":\"slow response\"}" + ``` diff --git a/examples/abort-signal/slow-server.mjs b/examples/abort-signal/slow-server.mjs new file mode 100644 index 00000000..c803ce5e --- /dev/null +++ b/examples/abort-signal/slow-server.mjs @@ -0,0 +1,37 @@ +// A minimal HTTP server that responds after a configurable delay. +// Used to test AbortSignal timeouts against a real slow endpoint. +// +// Usage: +// node slow-server.mjs # default 5s delay on port 3000 +// node slow-server.mjs 9000 10 # port 9000, 10s delay +// +// Endpoints: +// GET / → responds after seconds +// GET /delay/:ms → responds after :ms milliseconds + +import { createServer } from "node:http"; + +const PORT = parseInt(process.argv[2] || "3000", 10); +const DEFAULT_DELAY_S = parseInt(process.argv[3] || "5", 10); + +const server = createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const match = url.pathname.match(/^\/delay\/(\d+)$/); + const delayMs = match + ? parseInt(match[1], 10) + : DEFAULT_DELAY_S * 1000; + + console.log(`${req.method} ${url.pathname} → will respond in ${delayMs}ms`); + + const timer = setTimeout(() => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ delayed_ms: delayMs, message: "slow response" })); + }, delayMs); + + req.on("close", () => clearTimeout(timer)); +}); + +server.listen(PORT, () => { + console.log(`Slow server listening on http://localhost:${PORT}`); + console.log(`Default delay: ${DEFAULT_DELAY_S}s`); +}); diff --git a/examples/abort-signal/src/lib.rs b/examples/abort-signal/src/lib.rs new file mode 100644 index 00000000..010f4cd6 --- /dev/null +++ b/examples/abort-signal/src/lib.rs @@ -0,0 +1,76 @@ +use std::time::Duration; + +use futures_util::future::Either; +use worker::{ + event, AbortController, AbortSignal, Context, Delay, Env, Fetch, Request, Response, Result, + RouteContext, Router, +}; + +fn get_target_url(req: &Request) -> Result { + req.url()? + .query_pairs() + .find(|(k, _)| k == "url") + .map(|(_, v)| v.into_owned()) + .ok_or_else(|| worker::Error::RustError("Missing 'url' query param".into())) +} + +async fn abort_immediate(req: Request, _ctx: RouteContext<()>) -> Result { + let target = get_target_url(&req)?; + + let signal = AbortSignal::abort(); + let fetch = Fetch::Url(target.parse()?); + + match fetch.send_with_signal(&signal).await { + Ok(mut resp) => { + let text = resp.text().await?; + Response::ok(format!("Unexpected success: {text}")) + } + Err(e) => Response::ok(format!("Aborted: {e}")), + } +} + +async fn abort_timeout(req: Request, _ctx: RouteContext<()>) -> Result { + let target = get_target_url(&req)?; + + let timeout_ms: u64 = req + .url()? + .query_pairs() + .find(|(k, _)| k == "timeout") + .and_then(|(_, v)| v.parse().ok()) + .unwrap_or(2000); + + let url = target.parse()?; + let controller = AbortController::default(); + let signal = controller.signal(); + + let fetch_fut = async { + let mut resp = Fetch::Url(url).send_with_signal(&signal).await?; + let text = resp.text().await?; + Ok::<_, worker::Error>(text) + }; + + let timeout_fut = async { + Delay::from(Duration::from_millis(timeout_ms)).await; + controller.abort(); + }; + + futures_util::pin_mut!(fetch_fut); + futures_util::pin_mut!(timeout_fut); + + match futures_util::future::select(timeout_fut, fetch_fut).await { + Either::Left((_timed_out, _cancelled)) => { + Response::ok(format!("Request timed out after {timeout_ms}ms")) + } + Either::Right((Ok(body), _)) => Response::ok(format!("Got response: {body}")), + Either::Right((Err(e), _)) => Response::ok(format!("Fetch error: {e}")), + } +} + +#[event(fetch)] +pub async fn main(req: Request, env: Env, _ctx: Context) -> Result { + Router::new() + .get_async("/abort", abort_immediate) + .get_async("/timeout", abort_timeout) + .run(req, env) + .await +} diff --git a/examples/abort-signal/wrangler.toml b/examples/abort-signal/wrangler.toml new file mode 100644 index 00000000..06a6888f --- /dev/null +++ b/examples/abort-signal/wrangler.toml @@ -0,0 +1,6 @@ +name = "abort-signal-on-workers" +main = "build/worker/shim.mjs" +compatibility_date = "2026-04-28" + +[build] +command = "cargo install \"worker-build@^0.8\" && worker-build --release"