Skip to content

Commit 7b16e2a

Browse files
committed
chore: add rate limiting capability to the loggin.
1 parent 69fc5d0 commit 7b16e2a

3 files changed

Lines changed: 124 additions & 7 deletions

File tree

lambda-runtime/src/layers/api_response.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
use crate::{
2-
constants::LAMBDA_RUNTIME_INVOCATION_ID,
3-
deserializer,
4-
requests::{EventCompletionRequest, IntoRequest},
5-
runtime::LambdaInvocation,
6-
Diagnostic, EventErrorRequest, IntoFunctionResponse, LambdaEvent,
2+
Diagnostic, EventErrorRequest, IntoFunctionResponse, LambdaEvent, constants::LAMBDA_RUNTIME_INVOCATION_ID, deserializer, rate_limiter::RateLimiter, requests::{EventCompletionRequest, IntoRequest}, runtime::LambdaInvocation,
73
};
84
use futures::{ready, Stream};
95
use lambda_runtime_api_client::{body::Body, BoxError};
106
use pin_project::pin_project;
117
use serde::{Deserialize, Serialize};
12-
use std::{fmt::Debug, future::Future, marker::PhantomData, pin::Pin, task};
8+
use std::{fmt::Debug, future::Future, marker::PhantomData, pin::Pin, task, time::Duration};
139
use tower::Service;
1410
use tracing::{error, trace, warn};
1511

12+
13+
static MALFORMED_INVOCATION_ID_LIMITER: RateLimiter = RateLimiter::new(Duration::from_secs(60));
14+
1615
/// Tower service that turns the result or an error of a handler function into a Lambda Runtime API
1716
/// response.
1817
///
@@ -133,7 +132,14 @@ where
133132
Some(value) => match value.to_str() {
134133
Ok(value) => Some(value.to_owned()),
135134
Err(error) => {
136-
warn!(error = ?error, "Ignoring malformed Lambda runtime invocation ID header");
135+
136+
if MALFORMED_INVOCATION_ID_LIMITER.allow() {
137+
warn!(
138+
error = ?error,
139+
rate_limit_interval_ms = MALFORMED_INVOCATION_ID_LIMITER.interval().as_millis(),
140+
"Ignoring malformed Lambda runtime invocation ID header; this warning is rate limited"
141+
);
142+
}
137143
None
138144
}
139145
},

lambda-runtime/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ mod constants;
2929
pub mod diagnostic;
3030
pub use diagnostic::Diagnostic;
3131

32+
mod rate_limiter;
33+
3234
mod deserializer;
3335
/// Tower middleware to be applied to runtime invocations.
3436
pub mod layers;

lambda-runtime/src/rate_limiter.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//! Thread-safe, process-local rate limiting for infrequent runtime events.
2+
3+
use std::{
4+
sync::Mutex,
5+
time::{Duration, Instant},
6+
};
7+
8+
/// Allows an operation at most once during each configured interval.
9+
///
10+
/// A `RateLimiter` is intended to be shared by concurrent runtime tasks. When
11+
/// stored in a `static`, it is initialized once per Lambda execution environment
12+
/// and retains its state across warm invocations. A new cold-started environment
13+
/// receives a new limiter.
14+
pub(crate) struct RateLimiter {
15+
/// Minimum duration between allowed operations.
16+
interval: Duration,
17+
/// Timestamp of the most recent allowed operation.
18+
last_allowed: Mutex<Option<Instant>>,
19+
}
20+
21+
impl RateLimiter {
22+
/// Creates a rate limiter with the specified minimum interval.
23+
pub(crate) const fn new(interval: Duration) -> RateLimiter {
24+
RateLimiter {
25+
interval,
26+
last_allowed: Mutex::new(None),
27+
}
28+
}
29+
30+
/// Returns the minimum duration between allowed operations.
31+
pub(crate) const fn interval(&self) -> Duration {
32+
self.interval
33+
}
34+
35+
///
36+
/// The first call is allowed. Subsequent calls are rejected until the
37+
/// configured interval has elapsed since the previous allowed call.
38+
/// Concurrent callers are serialized while checking and updating the last
39+
/// allowed timestamp so only one caller crosses the interval boundary.
40+
pub(crate) fn allow(&self) -> bool {
41+
let mut last_allowed = match self.last_allowed.lock() {
42+
Ok(guard) => guard,
43+
Err(poisoned) => {
44+
// The limiter state is disposable, so reset it and recover instead of
45+
// allowing a poisoned mutex to crash the runtime or suppress future logs.
46+
let mut guard = poisoned.into_inner();
47+
*guard = None;
48+
self.last_allowed.clear_poison();
49+
guard
50+
}
51+
};
52+
53+
if last_allowed
54+
.as_ref()
55+
.is_some_and(|value| value.elapsed() < self.interval)
56+
{
57+
return false;
58+
}
59+
60+
*last_allowed = Some(Instant::now());
61+
62+
true
63+
}
64+
}
65+
66+
#[cfg(test)]
67+
mod tests {
68+
use super::*;
69+
use std::panic::{catch_unwind, AssertUnwindSafe};
70+
use std::thread;
71+
72+
#[test]
73+
fn allows_first_call() {
74+
let limiter = RateLimiter::new(Duration::from_secs(60));
75+
76+
assert!(limiter.allow());
77+
}
78+
79+
#[test]
80+
fn rejects_calls_inside_interval() {
81+
let limiter = RateLimiter::new(Duration::from_secs(60));
82+
83+
assert!(limiter.allow());
84+
assert!(!limiter.allow());
85+
}
86+
87+
#[test]
88+
fn allows_call_after_interval() {
89+
let limiter = RateLimiter::new(Duration::from_millis(10));
90+
91+
assert!(limiter.allow());
92+
thread::sleep(Duration::from_millis(15));
93+
94+
assert!(limiter.allow());
95+
}
96+
97+
#[test]
98+
fn recovers_from_poisoned_mutex() {
99+
let limiter = RateLimiter::new(Duration::from_secs(60));
100+
101+
let _ = catch_unwind(AssertUnwindSafe(|| {
102+
let _guard = limiter.last_allowed.lock().unwrap();
103+
panic!("poison the limiter mutex");
104+
}));
105+
106+
assert!(limiter.allow());
107+
assert!(!limiter.allow());
108+
}
109+
}

0 commit comments

Comments
 (0)