Skip to main content

ntex_util/services/
timeout.rs

1//! Service that applies a timeout to requests.
2//!
3//! If a service call does not complete within the configured timeout, its
4//! future is dropped and [`TimeoutError::Timeout`] is returned.
5//! If both complete during the same poll, the service result wins.
6use std::{fmt, marker::PhantomData};
7
8use ntex_service::{Ctx, IntoService, Middleware, Service};
9
10use crate::time::{Millis, timeout};
11
12/// Applies a timeout to requests.
13///
14/// A zero timeout disables the middleware.
15#[derive(Debug)]
16pub struct Timeout<St> {
17    timeout: Millis,
18    _t: PhantomData<St>,
19}
20
21/// Error returned by a timed service call.
22pub enum TimeoutError<E> {
23    /// Error returned by the wrapped service.
24    Service(E),
25    /// The service call exceeded its timeout.
26    Timeout,
27}
28
29impl<E> From<E> for TimeoutError<E> {
30    fn from(err: E) -> Self {
31        TimeoutError::Service(err)
32    }
33}
34
35impl<E: fmt::Debug> fmt::Debug for TimeoutError<E> {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            TimeoutError::Service(e) => write!(f, "TimeoutError::Service({e:?})"),
39            TimeoutError::Timeout => write!(f, "TimeoutError::Timeout"),
40        }
41    }
42}
43
44impl<E: fmt::Display> fmt::Display for TimeoutError<E> {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            TimeoutError::Service(e) => e.fmt(f),
48            TimeoutError::Timeout => write!(f, "Service call timeout"),
49        }
50    }
51}
52
53impl<E: fmt::Display + fmt::Debug> std::error::Error for TimeoutError<E> {}
54
55impl<E: PartialEq> PartialEq for TimeoutError<E> {
56    fn eq(&self, other: &TimeoutError<E>) -> bool {
57        match self {
58            TimeoutError::Service(e1) => match other {
59                TimeoutError::Service(e2) => e1 == e2,
60                TimeoutError::Timeout => false,
61            },
62            TimeoutError::Timeout => match other {
63                TimeoutError::Service(_) => false,
64                TimeoutError::Timeout => true,
65            },
66        }
67    }
68}
69
70impl<St> Timeout<St> {
71    /// Creates timeout middleware with the specified duration.
72    pub fn new<T: Into<Millis>>(timeout: T) -> Self {
73        Timeout {
74            timeout: timeout.into(),
75            _t: PhantomData,
76        }
77    }
78}
79
80impl<St> Clone for Timeout<St> {
81    fn clone(&self) -> Self {
82        Timeout {
83            timeout: self.timeout,
84            _t: PhantomData,
85        }
86    }
87}
88
89impl<S, St> Middleware<S, St> for Timeout<St> {
90    type Service = TimeoutService<S, St>;
91
92    fn create(&self, _: &St, service: S) -> Self::Service {
93        TimeoutService {
94            service,
95            timeout: self.timeout,
96            st: PhantomData,
97        }
98    }
99}
100
101/// A service that applies a timeout to each request.
102#[derive(Debug, Clone)]
103pub struct TimeoutService<S, St> {
104    service: S,
105    timeout: Millis,
106    st: PhantomData<St>,
107}
108
109impl<S, St> TimeoutService<S, St> {
110    /// Wraps a service with the specified per-request timeout.
111    pub fn new<T, Req>(timeout: T, service: impl IntoService<S, St, Req>) -> Self
112    where
113        T: Into<Millis>,
114        S: Service<St, Req>,
115    {
116        TimeoutService {
117            service: service.into_service(),
118            timeout: timeout.into(),
119            st: PhantomData,
120        }
121    }
122}
123
124impl<S, St, Req> Service<St, Req> for TimeoutService<S, St>
125where
126    S: Service<St, Req>,
127{
128    type Res = S::Res;
129    type Error = TimeoutError<S::Error>;
130
131    async fn call(&self, req: Req, ctx: Ctx<'_, Self, St>) -> Result<S::Res, Self::Error> {
132        if self.timeout.is_zero() {
133            ctx.call(&self.service, req)
134                .await
135                .map_err(TimeoutError::Service)
136        } else {
137            match timeout(self.timeout, ctx.call(&self.service, req)).await {
138                Ok(res) => res.map_err(TimeoutError::Service),
139                Err(()) => Err(TimeoutError::Timeout),
140            }
141        }
142    }
143
144    ntex_service::forward!(St, service, map_err = TimeoutError::Service);
145}
146
147#[cfg(test)]
148mod tests {
149    use std::time::Duration;
150
151    use ntex_service::{Pipeline, apply, fn_factory};
152
153    use super::*;
154
155    #[derive(Clone, Debug, PartialEq)]
156    struct SleepService(Duration);
157
158    #[derive(Clone, Debug, PartialEq)]
159    struct SrvError;
160
161    impl fmt::Display for SrvError {
162        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163            write!(f, "SrvError")
164        }
165    }
166
167    impl Service<(), ()> for SleepService {
168        type Res = ();
169        type Error = SrvError;
170
171        async fn call(&self, (): (), _: Ctx<'_, Self>) -> Result<(), SrvError> {
172            crate::time::sleep(self.0).await;
173            Ok::<_, SrvError>(())
174        }
175    }
176
177    /// STATEMENT: A service finishing before its timeout returns success.
178    ///
179    /// HOW:
180    /// - Clone a timeout service whose inner delay is shorter than its deadline.
181    /// - Verify a successful call and readiness, then complete shutdown.
182    #[ntex::test]
183    async fn test_success() {
184        let resolution = Duration::from_millis(100);
185        let wait_time = Duration::from_millis(50);
186
187        let timeout = Pipeline::new(
188            (),
189            TimeoutService::new(resolution, SleepService(wait_time)).clone(),
190        );
191        assert_eq!(timeout.call(()).await, Ok(()));
192        assert_eq!(timeout.ready().await, Ok(()));
193        timeout.shutdown().await;
194    }
195
196    /// STATEMENT: A zero timeout disables the deadline rather than rejecting delayed calls.
197    ///
198    /// HOW:
199    /// - Wrap a sleeping service with a zero timeout and verify its call and readiness succeed.
200    #[ntex::test]
201    async fn test_zero() {
202        let wait_time = Duration::from_millis(50);
203        let resolution = Duration::from_millis(0);
204
205        let timeout = Pipeline::new((), TimeoutService::new(resolution, SleepService(wait_time)));
206        assert_eq!(timeout.call(()).await, Ok(()));
207        assert_eq!(timeout.ready().await, Ok(()));
208    }
209
210    /// STATEMENT: Calls exceeding their deadline return `TimeoutError::Timeout`.
211    ///
212    /// HOW:
213    /// - Give a sleeping service a shorter timeout and compare the returned error.
214    #[ntex::test]
215    async fn test_timeout() {
216        let resolution = Duration::from_millis(100);
217        let wait_time = Duration::from_millis(500);
218
219        let timeout = Pipeline::new((), TimeoutService::new(resolution, SleepService(wait_time)));
220        assert_eq!(timeout.call(()).await, Err(TimeoutError::Timeout));
221    }
222
223    /// STATEMENT: Factory-applied timeout middleware enforces its configured deadline.
224    ///
225    /// HOW:
226    /// - Apply cloned timeout middleware to a factory producing a slower service.
227    /// - Create its pipeline and verify the call returns the timeout variant.
228    #[ntex::test]
229    #[allow(clippy::redundant_clone)]
230    async fn test_timeout_middleware() {
231        let resolution = Duration::from_millis(100);
232        let wait_time = Duration::from_millis(500);
233
234        let timeout = apply(
235            Timeout::new(resolution).clone(),
236            fn_factory(async move |()| Ok::<_, ()>(SleepService(wait_time))),
237        );
238        let srv = timeout.pipeline(()).await.unwrap();
239
240        let res = srv.call(()).await.unwrap_err();
241        assert_eq!(res, TimeoutError::Timeout);
242    }
243
244    /// STATEMENT: Timeout errors distinguish elapsed deadlines from wrapped service errors.
245    ///
246    /// HOW:
247    /// - Format a timeout variant and a converted service error.
248    /// - Check their debug variants and human-readable messages.
249    #[test]
250    fn test_error() {
251        let err1 = TimeoutError::<SrvError>::Timeout;
252        assert!(format!("{err1:?}").contains("TimeoutError::Timeout"));
253        assert!(format!("{err1}").contains("Service call timeout"));
254
255        let err2: TimeoutError<_> = SrvError.into();
256        assert!(format!("{err2:?}").contains("TimeoutError::Service"));
257        assert!(format!("{err2}").contains("SrvError"));
258    }
259
260    /// STATEMENT: Timeout-error equality compares both the variant and any service error.
261    ///
262    /// HOW:
263    /// - Compare matching and differing service errors, mixed variants, and two timeout variants.
264    #[test]
265    fn timeout_error_eq() {
266        assert_eq!(TimeoutError::Service(1), TimeoutError::Service(1));
267        assert_ne!(TimeoutError::Service(1), TimeoutError::Service(2));
268        assert_ne!(TimeoutError::Service(1), TimeoutError::Timeout);
269        assert_ne!(TimeoutError::Timeout, TimeoutError::Service(1));
270        assert_eq!(TimeoutError::<u8>::Timeout, TimeoutError::Timeout);
271    }
272}