Skip to content

Commit 380227d

Browse files
committed
feat: add router/route timeout
1 parent 1fa8493 commit 380227d

2 files changed

Lines changed: 121 additions & 2 deletions

File tree

src/route.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
3131
use std::collections::VecDeque;
3232
use std::sync::Arc;
33+
use std::time::Duration;
3334
#[cfg(feature = "plugins")]
3435
use std::sync::atomic::AtomicBool;
3536
#[cfg(feature = "plugins")]
@@ -79,6 +80,8 @@ pub struct Route {
7980
/// OpenAPI metadata for this route.
8081
#[cfg(any(feature = "utoipa", feature = "vespera"))]
8182
pub(crate) openapi: RwLock<Option<RouteOpenApi>>,
83+
/// Route-specific timeout override.
84+
pub(crate) timeout: RwLock<Option<Duration>>,
8285
}
8386

8487
impl Route {
@@ -99,6 +102,7 @@ impl Route {
99102
signals: SignalArbiter::new(),
100103
#[cfg(any(feature = "utoipa", feature = "vespera"))]
101104
openapi: RwLock::new(None),
105+
timeout: RwLock::new(None),
102106
}
103107
}
104108

@@ -421,4 +425,28 @@ impl Route {
421425
pub fn openapi_metadata(&self) -> Option<RouteOpenApi> {
422426
self.openapi.read().clone()
423427
}
428+
429+
/// Sets a timeout for this route, overriding the router-level timeout.
430+
///
431+
/// When a request exceeds the timeout duration, the timeout fallback handler
432+
/// is invoked (if configured on the router) or a 408 Request Timeout response
433+
/// is returned.
434+
///
435+
/// # Examples
436+
///
437+
/// ```rust,ignore
438+
/// use std::time::Duration;
439+
///
440+
/// router.route(Method::POST, "/upload", upload_handler)
441+
/// .timeout(Duration::from_secs(60));
442+
/// ```
443+
pub fn timeout(&self, duration: Duration) -> &Self {
444+
*self.timeout.write() = Some(duration);
445+
self
446+
}
447+
448+
/// Returns the configured timeout for this route, if any.
449+
pub(crate) fn get_timeout(&self) -> Option<Duration> {
450+
*self.timeout.read()
451+
}
424452
}

src/router.rs

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use std::collections::HashMap;
3333
use std::sync::Arc;
3434
use std::sync::Weak;
35+
use std::time::Duration;
3536
#[cfg(feature = "plugins")]
3637
use std::sync::atomic::AtomicBool;
3738

@@ -105,6 +106,10 @@ pub struct Router {
105106
/// Signal arbiter for in-process event emission and handling.
106107
#[cfg(feature = "signals")]
107108
signals: SignalArbiter,
109+
/// Default timeout for all routes.
110+
pub(crate) timeout: Option<Duration>,
111+
/// Fallback handler executed when a request times out.
112+
timeout_fallback: Option<BoxHandler>,
108113
}
109114

110115
impl Default for Router {
@@ -129,6 +134,8 @@ impl Router {
129134
plugins_initialized: AtomicBool::new(false),
130135
#[cfg(feature = "signals")]
131136
signals: SignalArbiter::new(),
137+
timeout: None,
138+
timeout_fallback: None,
132139
};
133140

134141
#[cfg(feature = "signals")]
@@ -274,6 +281,39 @@ impl Router {
274281
next.run(req).await
275282
}
276283

284+
/// Executes the middleware chain with an optional timeout.
285+
///
286+
/// If a timeout is specified and exceeded, the timeout fallback handler
287+
/// is invoked or a default 408 Request Timeout response is returned.
288+
async fn run_with_timeout(
289+
&self,
290+
req: Request,
291+
next: Next,
292+
timeout_duration: Option<Duration>,
293+
) -> Response {
294+
match timeout_duration {
295+
Some(duration) => {
296+
match tokio::time::timeout(duration, next.run(req)).await {
297+
Ok(response) => response,
298+
Err(_elapsed) => self.handle_timeout().await,
299+
}
300+
}
301+
None => next.run(req).await,
302+
}
303+
}
304+
305+
/// Returns the timeout response using the fallback handler or a default 408.
306+
async fn handle_timeout(&self) -> Response {
307+
if let Some(handler) = &self.timeout_fallback {
308+
handler.call(Request::default()).await
309+
} else {
310+
http::Response::builder()
311+
.status(StatusCode::REQUEST_TIMEOUT)
312+
.body(TakoBody::empty())
313+
.expect("valid 408 response")
314+
}
315+
}
316+
277317
/// Dispatches an incoming request to the appropriate route handler.
278318
pub async fn dispatch(&self, mut req: Request) -> Response {
279319
let method = req.method().clone();
@@ -315,6 +355,9 @@ impl Router {
315355
endpoint: Arc::new(route.handler.clone()),
316356
};
317357

358+
// Determine effective timeout: route-level overrides router-level
359+
let effective_timeout = route.get_timeout().or(self.timeout);
360+
318361
#[cfg(feature = "signals")]
319362
{
320363
let method_str = method.to_string();
@@ -331,7 +374,7 @@ impl Router {
331374
))
332375
.await;
333376

334-
let response = next.run(req).await;
377+
let response = self.run_with_timeout(req, next, effective_timeout).await;
335378

336379
let mut done_meta: HashMap<String, String, BuildHasher> =
337380
HashMap::with_hasher(BuildHasher::default());
@@ -350,7 +393,7 @@ impl Router {
350393

351394
#[cfg(not(feature = "signals"))]
352395
{
353-
return next.run(req).await;
396+
return self.run_with_timeout(req, next, effective_timeout).await;
354397
}
355398
}
356399

@@ -552,6 +595,54 @@ impl Router {
552595
self
553596
}
554597

598+
/// Sets a default timeout for all routes.
599+
///
600+
/// This timeout can be overridden on individual routes using `Route::timeout`.
601+
/// When a request exceeds the timeout duration, the timeout fallback handler
602+
/// is invoked (if configured) or a 408 Request Timeout response is returned.
603+
///
604+
/// # Examples
605+
///
606+
/// ```rust
607+
/// use tako::router::Router;
608+
/// use std::time::Duration;
609+
///
610+
/// let mut router = Router::new();
611+
/// router.timeout(Duration::from_secs(30));
612+
/// ```
613+
pub fn timeout(&mut self, duration: Duration) -> &mut Self {
614+
self.timeout = Some(duration);
615+
self
616+
}
617+
618+
/// Sets a fallback handler that will be executed when a request times out.
619+
///
620+
/// If no timeout fallback is set, a default 408 Request Timeout response is returned.
621+
///
622+
/// # Examples
623+
///
624+
/// ```rust
625+
/// use tako::{router::Router, responder::Responder, types::Request};
626+
/// use std::time::Duration;
627+
///
628+
/// async fn timeout_handler(_req: Request) -> impl Responder {
629+
/// "Request took too long"
630+
/// }
631+
///
632+
/// let mut router = Router::new();
633+
/// router.timeout(Duration::from_secs(30));
634+
/// router.timeout_fallback(timeout_handler);
635+
/// ```
636+
pub fn timeout_fallback<F, Fut, R>(&mut self, handler: F) -> &mut Self
637+
where
638+
F: Fn(Request) -> Fut + Clone + Send + Sync + 'static,
639+
Fut: std::future::Future<Output = R> + Send + 'static,
640+
R: Responder + Send + 'static,
641+
{
642+
self.timeout_fallback = Some(BoxHandler::new::<F, (Request,)>(handler));
643+
self
644+
}
645+
555646
/// Registers a plugin with the router.
556647
///
557648
/// Plugins extend the router's functionality by providing additional features

0 commit comments

Comments
 (0)