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
22use crate::idempotency::build_rpc_op_resolver;
23
24/// Build a rate-limit filter function for RPC schemas.
25///
26/// Returns a function that:
27/// - Extracts `op_id` from `/rpc/{op_id}` paths
28/// - Looks up the op in the provided descriptors
29/// - Returns `false` (exempt) if `rate_limited_by_default` is false
30/// - Returns `true` (apply rate limit) if the op participates, or if lookup fails
31///
32/// Fails closed: if descriptor lookup misses for any reason, the op is
33/// rate-limited. This prevents accidental exemptions from missing data.
34///
35/// `/rpc/batch` is always rate-limited regardless of its contents — see
36/// the module-level "Known limitation" doc above.
37///
38/// Since ADR 0015 slice 2 (cratestack#877) this is a projection of
39/// [`build_rpc_op_resolver`] — one lookup shared with idempotency. Every
40/// case above that resolver answers `OpAdmission::unresolved`, whose
41/// `rate_limited_by_default` is `true`: the same fail-closed answer. For a
42/// router mounted with `Router::nest`, pass
43/// `build_rpc_op_resolver_with_prefix` to
44/// [`super::RateLimitLayer::with_op_resolver`] instead.
45pub fn build_rpc_ops_filter(
46    ops: &'static [OpDescriptor],
47) -> impl Fn(&Request) -> bool + Send + Sync {
48    let resolve = build_rpc_op_resolver(ops);
49    move |req: &Request| resolve(req).rate_limited_by_default
50}
51
52#[cfg(test)]
53mod tests {
54    use axum::body::Body;
55    use axum::http::Request;
56    use cratestack_core::{OpDescriptor, OpKind};
57
58    use super::build_rpc_ops_filter;
59
60    const OPS: &[OpDescriptor] = &[
61        OpDescriptor {
62            op_id: "procedure.createPayment",
63            kind: OpKind::Unary,
64            input_ty: "PingArgs",
65            output_ty: "PingArgs",
66            idempotent_by_default: false,
67            rate_limited_by_default: false,
68            auth_required: true,
69        },
70        OpDescriptor {
71            op_id: "procedure.ping",
72            kind: OpKind::Unary,
73            input_ty: "PingArgs",
74            output_ty: "PingArgs",
75            idempotent_by_default: true,
76            rate_limited_by_default: true,
77            auth_required: true,
78        },
79    ];
80
81    fn get(uri: &str) -> Request<Body> {
82        Request::builder()
83            .method("POST")
84            .uri(uri)
85            .body(Body::empty())
86            .expect("request should build")
87    }
88
89    #[test]
90    fn exempts_no_rate_limit_op_and_throttles_ordinary_op() {
91        let filter = build_rpc_ops_filter(OPS);
92
93        assert!(
94            !filter(&get("/rpc/procedure.createPayment")),
95            "op with rate_limited_by_default: false should be exempt"
96        );
97        assert!(
98            filter(&get("/rpc/procedure.ping")),
99            "op with rate_limited_by_default: true should be rate-limited"
100        );
101    }
102
103    #[test]
104    fn batch_and_subscribe_are_always_rate_limited() {
105        let filter = build_rpc_ops_filter(OPS);
106
107        assert!(
108            filter(&get("/rpc/batch")),
109            "/rpc/batch is a framework dispatch point, always rate-limited \
110             (see module docs: it can't see per-op exemptions inside the batch body)"
111        );
112        assert!(
113            filter(&get("/rpc/subscribe/model.Widget.subscribe")),
114            "/rpc/subscribe/* is a framework dispatch point, always rate-limited"
115        );
116    }
117
118    #[test]
119    fn unknown_op_and_non_rpc_path_fail_closed() {
120        let filter = build_rpc_ops_filter(OPS);
121
122        assert!(
123            filter(&get("/rpc/procedure.doesNotExist")),
124            "an op missing from the descriptor array must fail closed (rate-limited)"
125        );
126        assert!(
127            filter(&get("/api/widgets")),
128            "a non-RPC path must fail closed (rate-limited)"
129        );
130    }
131}