Skip to main content

cratestack_axum/ratelimit/
rpc_ops_filter.rs

1//! Rate-limit filter for RPC transport: check if an operation is exempt
2//! from rate limiting based on its `OpDescriptor.rate_limited_by_default`.
3//!
4//! # Known limitation: `/rpc/batch` is not per-op
5//!
6//! `POST /rpc/batch` (`docs/design/rpc-transport.md`) carries a *sequence*
7//! of ops in one request body. This filter runs before the body is
8//! decoded — it only sees the HTTP-layer path — so it cannot look inside
9//! a batch to exempt individual ops the way it can for `/rpc/{op_id}`.
10//! **Accepted tradeoff, not a bug**: `/rpc/batch` is therefore always
11//! rate-limited wholesale, regardless of whether every op it contains is
12//! `@no_rate_limit`. An op author who needs the exemption to hold
13//! unconditionally must call it via `/rpc/{op_id}`, not batch it. Making
14//! batch itself descriptor-aware would require decoding + re-encoding the
15//! batch body inside this HTTP-layer filter (or moving enforcement into
16//! the batch dispatcher itself), which is out of scope here — see
17//! cratestack#474's discussion for the full reasoning.
18
19use axum::extract::Request;
20use cratestack_core::OpDescriptor;
21
22/// Build a rate-limit filter function for RPC schemas.
23///
24/// Returns a function that:
25/// - Extracts `op_id` from `/rpc/{op_id}` paths
26/// - Looks up the op in the provided descriptors
27/// - Returns `false` (exempt) if `rate_limited_by_default` is false
28/// - Returns `true` (apply rate limit) if the op participates, or if lookup fails
29///
30/// Fails closed: if descriptor lookup misses for any reason, the op is
31/// rate-limited. This prevents accidental exemptions from missing data.
32///
33/// `/rpc/batch` is always rate-limited regardless of its contents — see
34/// the module-level "Known limitation" doc above.
35pub fn build_rpc_ops_filter(
36    ops: &'static [OpDescriptor],
37) -> impl Fn(&Request) -> bool + Send + Sync {
38    move |req: &Request| {
39        let path = req.uri().path();
40
41        // Only apply descriptor lookup to `/rpc/{op_id}` paths.
42        if !path.starts_with("/rpc/") {
43            // Not an RPC path; default to rate-limit.
44            return true;
45        }
46
47        // Extract op_id from `/rpc/{op_id}` (strip `/rpc/` prefix).
48        // Note: `/rpc/batch` and `/rpc/subscribe/{op_id}` are handled separately
49        // by RPC dispatch and subscription endpoints, not generic ops. Only
50        // unary ops live at `/rpc/{op_id}`.
51        let op_id = &path[5..]; // skip "/rpc/"
52
53        // If the path is `/rpc/batch` or `/rpc/subscribe/...`, those are
54        // framework dispatch points, not op invocations. Rate-limit them.
55        if op_id == "batch" || op_id.starts_with("subscribe/") {
56            return true;
57        }
58
59        // Look up the op in the descriptor array.
60        // Note: The array is not sorted, so we use linear search.
61        match ops.iter().find(|op| op.op_id == op_id) {
62            Some(op) => {
63                // Op found. Return whether it should be rate-limited.
64                op.rate_limited_by_default
65            }
66            None => {
67                // Op not found in descriptors. Fail closed: rate-limit it.
68                // This could indicate a malformed op_id or a schema mismatch,
69                // so treating it conservatively is correct.
70                true
71            }
72        }
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use axum::body::Body;
79    use axum::http::Request;
80    use cratestack_core::{OpDescriptor, OpKind};
81
82    use super::build_rpc_ops_filter;
83
84    const OPS: &[OpDescriptor] = &[
85        OpDescriptor {
86            op_id: "procedure.createPayment",
87            kind: OpKind::Unary,
88            input_ty: "PingArgs",
89            output_ty: "PingArgs",
90            idempotent_by_default: false,
91            rate_limited_by_default: false,
92            auth_required: true,
93        },
94        OpDescriptor {
95            op_id: "procedure.ping",
96            kind: OpKind::Unary,
97            input_ty: "PingArgs",
98            output_ty: "PingArgs",
99            idempotent_by_default: true,
100            rate_limited_by_default: true,
101            auth_required: true,
102        },
103    ];
104
105    fn get(uri: &str) -> Request<Body> {
106        Request::builder()
107            .method("POST")
108            .uri(uri)
109            .body(Body::empty())
110            .expect("request should build")
111    }
112
113    #[test]
114    fn exempts_no_rate_limit_op_and_throttles_ordinary_op() {
115        let filter = build_rpc_ops_filter(OPS);
116
117        assert!(
118            !filter(&get("/rpc/procedure.createPayment")),
119            "op with rate_limited_by_default: false should be exempt"
120        );
121        assert!(
122            filter(&get("/rpc/procedure.ping")),
123            "op with rate_limited_by_default: true should be rate-limited"
124        );
125    }
126
127    #[test]
128    fn batch_and_subscribe_are_always_rate_limited() {
129        let filter = build_rpc_ops_filter(OPS);
130
131        assert!(
132            filter(&get("/rpc/batch")),
133            "/rpc/batch is a framework dispatch point, always rate-limited \
134             (see module docs: it can't see per-op exemptions inside the batch body)"
135        );
136        assert!(
137            filter(&get("/rpc/subscribe/model.Widget.subscribe")),
138            "/rpc/subscribe/* is a framework dispatch point, always rate-limited"
139        );
140    }
141
142    #[test]
143    fn unknown_op_and_non_rpc_path_fail_closed() {
144        let filter = build_rpc_ops_filter(OPS);
145
146        assert!(
147            filter(&get("/rpc/procedure.doesNotExist")),
148            "an op missing from the descriptor array must fail closed (rate-limited)"
149        );
150        assert!(
151            filter(&get("/api/widgets")),
152            "a non-RPC path must fail closed (rate-limited)"
153        );
154    }
155}