Skip to content

Commit 7ab2a9d

Browse files
authored
feat: add tower-service compatibility to seatbelt (#252)
As title says. Seatbelt can now interop natively with tower-based ecosystem. The downside is decreased ergonomics and performance.
1 parent 64d0a2e commit 7ab2a9d

17 files changed

Lines changed: 865 additions & 192 deletions

File tree

Cargo.lock

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ proc-macro2 = { version = "1.0.103", default-features = false }
8585
quote = { version = "1.0.42", default-features = false }
8686
rapidhash = { version = "4.1.1", default-features = false }
8787
regex = { version = "1.12.2", default-features = false }
88+
rstest = { version = "0.24", default-features = false }
8889
rustc-hash = { version = "2.1.0", default-features = false }
8990
serde = { version = "1.0.228", default-features = false }
9091
serde_core = { version = "1.0.228", default-features = false }

crates/seatbelt/AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# AI Agents Guidelines for `seatbelt`
2+
3+
Code in this crate should follow the [Microsoft Rust Guidelines](https://microsoft.github.io/rust-guidelines/agents/all.txt).
4+
5+
## Dual Service Implementations
6+
7+
Each middleware has two logic-equivalent `Service` trait impls in the same file: one for
8+
`layered::Service` (`execute`) and one for `tower_service::Service` (`call`). The business
9+
logic flow must be identical between the two — they differ only mechanically (boxed futures,
10+
`poll_ready`, `Arc` cloning, `Result` output type).
11+
12+
**Any change to `execute` must be mirrored in `call`, and vice versa.** Prefer adding logic to
13+
the shared `*Shared` helper structs rather than inlining into both methods. Integration tests
14+
use `rstest` cases that exercise both implementations — new test scenarios should do the same.

crates/seatbelt/Cargo.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ allowed_external_types = [
2626
"recoverable::RecoveryKind",
2727
"tick::clock::Clock",
2828
"tower_layer::Layer",
29+
"tower_service::Service",
2930
]
3031

3132
[package.metadata.docs.rs]
@@ -38,13 +39,15 @@ retry = ["dep:fastrand"]
3839
breaker = ["dep:fastrand"]
3940
metrics = ["dep:opentelemetry", "opentelemetry/metrics"]
4041
logs = ["dep:tracing"]
42+
tower-service = ["dep:tower-service"]
4143

4244
[dependencies]
4345
fastrand = { workspace = true, optional = true }
4446
layered = { workspace = true }
4547
opentelemetry = { workspace = true, optional = true }
4648
recoverable = { workspace = true }
4749
tick = { workspace = true }
50+
tower-service = { workspace = true, optional = true }
4851
tracing = { workspace = true, optional = true }
4952

5053
[dev-dependencies]
@@ -53,15 +56,18 @@ criterion.workspace = true
5356
fastrand.workspace = true
5457
futures = { workspace = true, features = ["executor"] }
5558
http.workspace = true
56-
layered = { workspace = true }
59+
layered = { workspace = true, features = ["tower-service"] }
5760
mutants.workspace = true
5861
ohno = { workspace = true, features = ["app-err"] }
5962
opentelemetry = { workspace = true, default-features = false, features = ["metrics"] }
6063
opentelemetry-stdout = { workspace = true, default-features = false, features = ["metrics", "logs"] }
6164
opentelemetry_sdk = { workspace = true, default-features = false, features = ["metrics", "testing", "experimental_metrics_custom_reader"] }
65+
rstest.workspace = true
6266
static_assertions.workspace = true
6367
tick = { workspace = true, features = ["test-util", "tokio"] }
6468
tokio = { workspace = true, features = ["rt", "macros"] }
69+
tower = { workspace = true, features = ["util"] }
70+
tower-service.workspace = true
6571
tracing.workspace = true
6672
tracing-subscriber = { workspace = true, features = ["fmt", "std"] }
6773

@@ -89,6 +95,10 @@ required-features = ["retry"]
8995
name = "resilience_pipeline"
9096
required-features = ["retry", "timeout"]
9197

98+
[[example]]
99+
name = "tower"
100+
required-features = ["retry", "timeout", "tower-service"]
101+
92102
[[example]]
93103
name = "breaker"
94104
required-features = ["breaker", "metrics"]

crates/seatbelt/README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,39 @@ for each module for details on how to use them.
9191
* [`retry`][__link9] - Middleware that automatically retries failed operations.
9292
* [`breaker`][__link10] - Middleware that prevents cascading failures.
9393

94+
## Tower Compatibility
95+
96+
All resilience middleware are compatible with the Tower ecosystem when the `tower-service`
97+
feature is enabled. This allows you to use `tower::ServiceBuilder` to compose middleware stacks:
98+
99+
```rust
100+
use seatbelt::retry::Retry;
101+
use seatbelt::timeout::Timeout;
102+
use seatbelt::{RecoveryInfo, ResilienceContext};
103+
use tower::ServiceBuilder;
104+
105+
let context: ResilienceContext<String, Result<String, String>> =
106+
ResilienceContext::new(&clock);
107+
108+
let service = ServiceBuilder::new()
109+
.layer(
110+
Retry::layer("my_retry", &context)
111+
.clone_input()
112+
.recovery_with(|result: &Result<String, String>, _| match result {
113+
Ok(_) => RecoveryInfo::never(),
114+
Err(_) => RecoveryInfo::retry(),
115+
}),
116+
)
117+
.layer(
118+
Timeout::layer("my_timeout", &context)
119+
.timeout(Duration::from_secs(30))
120+
.timeout_error(|_| "operation timed out".to_string()),
121+
)
122+
.service_fn(|input: String| async move {
123+
Ok::<_, String>(format!("processed: {input}"))
124+
});
125+
```
126+
94127
## Features
95128

96129
This crate provides several optional features that can be enabled in your `Cargo.toml`:
@@ -101,20 +134,23 @@ This crate provides several optional features that can be enabled in your `Cargo
101134
* **`breaker`** - Enables the [`breaker`][__link13] middleware for preventing cascading failures.
102135
* **`metrics`** - Exposes the OpenTelemetry metrics API for collecting and reporting metrics.
103136
* **`logs`** - Enables structured logging for resilience middleware using the `tracing` crate.
137+
* **`tower-service`** - Enables [`tower_service::Service`][__link14] trait implementations for all
138+
resilience middleware.
104139

105140

106141
<hr/>
107142
<sub>
108143
This crate was developed as part of <a href="../..">The Oxidizer Project</a>. Browse this crate's <a href="https://github.com/microsoft/oxidizer/tree/main/crates/seatbelt">source code</a>.
109144
</sub>
110145

111-
[__cargo_doc2readme_dependencies_info]: ggGkYW0CYXSEGy4k8ldDFPOhG2VNeXtD5nnKG6EPY6OfW5wBG8g18NOFNdxpYXKEG-dV5_BMYStiGz3ob6YYJlksG_4wPpDb0HzfG4spY1f17r0dYWSEgmdsYXllcmVkZTAuMy4wgmtyZWNvdmVyYWJsZWUwLjEuMIJoc2VhdGJlbHRlMC4yLjCCZHRpY2tlMC4xLjI
146+
[__cargo_doc2readme_dependencies_info]: ggGkYW0CYXSEGy4k8ldDFPOhG2VNeXtD5nnKG6EPY6OfW5wBG8g18NOFNdxpYXKEG4m9a89kCnclG9jGIjV2D_1yGzTaydkW8mVgG84_sI5bv3pNYWSFgmdsYXllcmVkZTAuMy4wgmtyZWNvdmVyYWJsZWUwLjEuMIJoc2VhdGJlbHRlMC4yLjCCZHRpY2tlMC4xLjKCbXRvd2VyX3NlcnZpY2VlMC4zLjM
112147
[__link0]: https://crates.io/crates/layered/0.3.0
113148
[__link1]: https://docs.rs/layered/0.3.0/layered/?search=Stack
114149
[__link10]: https://docs.rs/seatbelt/0.2.0/seatbelt/breaker/index.html
115150
[__link11]: https://docs.rs/seatbelt/0.2.0/seatbelt/timeout/index.html
116151
[__link12]: https://docs.rs/seatbelt/0.2.0/seatbelt/retry/index.html
117152
[__link13]: https://docs.rs/seatbelt/0.2.0/seatbelt/breaker/index.html
153+
[__link14]: https://docs.rs/tower_service/0.3.3/tower_service/?search=Service
118154
[__link2]: https://docs.rs/tick/0.1.2/tick/?search=Clock
119155
[__link3]: https://crates.io/crates/tick/0.1.2
120156
[__link4]: https://docs.rs/seatbelt/0.2.0/seatbelt/?search=ResilienceContext

crates/seatbelt/examples/tower.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
//! This example demonstrates how to combine multiple resilience middlewares
5+
//! using the `seatbelt` crate with Tower's `ServiceBuilder` to create a robust
6+
//! execution pipeline compatible with the Tower ecosystem.
7+
8+
use std::future::poll_fn;
9+
use std::time::Duration;
10+
11+
use ohno::{app_err, bail};
12+
use seatbelt::retry::Retry;
13+
use seatbelt::timeout::Timeout;
14+
use seatbelt::{RecoveryInfo, ResilienceContext};
15+
use tick::Clock;
16+
use tower::ServiceBuilder;
17+
use tower_service::Service;
18+
19+
#[tokio::main]
20+
async fn main() -> Result<(), ohno::AppError> {
21+
// Shared context for resilience middleware
22+
let context = ResilienceContext::new(Clock::new_tokio()).name("tower_pipeline");
23+
24+
// Build a Tower service with retry and timeout middlewares using ServiceBuilder.
25+
// Layers are applied bottom-to-top: timeout wraps the inner service first,
26+
// then retry wraps the timeout layer.
27+
let mut service = ServiceBuilder::new()
28+
.layer(
29+
Retry::layer("my_retry", &context)
30+
.clone_input()
31+
.recovery_with(|output, _args| match output {
32+
Ok(_) => RecoveryInfo::never(),
33+
Err(_) => RecoveryInfo::retry(),
34+
}),
35+
)
36+
.layer(
37+
Timeout::layer("my_timeout", &context)
38+
.timeout(Duration::from_secs(1))
39+
.timeout_error(|_args| app_err!("timeout")),
40+
)
41+
.service_fn(|request| async move {
42+
if fastrand::i16(0..10) > 4 {
43+
bail!("random failure")
44+
}
45+
Ok(request)
46+
});
47+
48+
// Execute the service using Tower's Service trait
49+
poll_fn(|cx| service.poll_ready(cx)).await?;
50+
let output = service.call("value".to_string()).await?;
51+
52+
println!("execution finished, output: {output}");
53+
54+
Ok(())
55+
}

crates/seatbelt/src/breaker/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,8 @@ pub(super) use callbacks::*;
291291
pub use layer::BreakerLayer;
292292
#[doc(inline)]
293293
pub use service::Breaker;
294+
#[cfg(feature = "tower-service")]
295+
pub use service::BreakerFuture;
294296
pub(crate) use service::BreakerShared;
295297

296298
mod execution_result;

0 commit comments

Comments
 (0)