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}