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}