How to Provide Both Async and Sync APIs in a Rust Library Without Duplicating Code
This article walks through the challenges of offering both asynchronous and synchronous interfaces for a Rust API client, evaluates naive copy‑paste solutions, explores runtime‑based wrappers and the maybe_async procedural macro, and finally presents a clean, feature‑flagged design that avoids code duplication and compile‑time overhead.
Problem
A Rust library that wraps a public API (e.g., Spotify or ArangoDB) is implemented asynchronously using tokio. Users request a synchronous API similar to reqwest::blocking without sprinkling block_on(...) throughout their code.
First Method – Duplicate Code
Copy the entire client into a new blocking module, replace reqwest with reqwest::blocking, and remove async / .await. This yields a rspotify::blocking::Client alongside the async client.
Every endpoint is written twice.
Maintaining parity is error‑prone and slows development.
Async‑only users incur a larger binary because the blocking module is compiled in.
Human copy‑paste errors increase with more contributors.
Second Method – Runtime‑Based Wrapper
Keep a single async implementation and expose a synchronous wrapper that calls block_on on a runtime.
mod blocking {
struct Spotify(super::Spotify);
impl Spotify {
fn endpoint(&self, param: String) -> SpotifyResult<String> {
runtime.block_on(async move { self.0.endpoint(param).await })
}
}
}Creating a new tokio runtime per call is expensive:
let mut runtime = tokio::runtime::Builder::new()
.basic_scheduler()
.enable_all()
.build()
.unwrap();A shared runtime can be stored in a global lazy_static or accessed via tokio::runtime::Handle:
use tokio::runtime::Runtime;
lazy_static! {
static ref RT: Runtime = Runtime::new().unwrap();
}
fn endpoint(&self, param: String) -> SpotifyResult<String> {
RT.handle().block_on(async move { self.0.endpoint(param).await })
}This reduces overhead but still requires a runtime dependency for the blocking client and does not eliminate duplicated method signatures.
Feature‑Based Crate Split
Creating separate crates (e.g., rspotify-sync and rspotify-async) runs into Cargo’s feature unification: enabling both sync and async features simultaneously can cause conflicts, as demonstrated by a minimal reproducer repository.
Third Method – maybe_async Procedural Macro
The maybe_async crate can automatically generate sync or async versions of functions, traits, and impl blocks based on a feature flag ( is_sync).
#[maybe_async::maybe_async]
async fn endpoint() { /* ... */ }When the is_sync feature is disabled, the function remains async; when enabled, the macro strips async and .await and emits a synchronous version.
#[cfg(not(feature = "is_sync"))]
async fn endpoint() { /* ... */ }
#[cfg(feature = "is_sync")]
fn endpoint() { /* ... with .await removed */ }Using this macro, the library can expose a single code base and let users select the desired client via Cargo features (e.g., client-ureq for a synchronous ureq client or client-reqwest for an async reqwest client). No extra runtime or heavy dependencies are pulled in for the unused path.
Benefits
No duplicated endpoint implementations.
Binary size stays minimal; sync users can choose a lightweight client like ureq without pulling tokio.
Feature flags are simple to configure in Cargo.toml.
Trait‑Based HTTP‑Client Abstraction
Define a trait for the HTTP client and implement it for both async ( reqwest) and sync ( ureq) backends. The macro can generate the appropriate impls.
#[maybe_async]
trait HttpClient {
async fn get(&self) -> String;
}
#[sync_impl]
impl HttpClient for UreqClient {
fn get(&self) -> String { ureq::get(/* ... */) }
}
#[async_impl]
impl HttpClient for ReqwestClient {
async fn get(&self) -> String { reqwest::get(/* ... */).await }
}
struct SpotifyClient<Http: HttpClient> { http: Http }
#[maybe_async]
impl<Http: HttpClient> SpotifyClient<Http> {
async fn endpoint(&self) { self.http.get().await }
}Users enable the desired client feature in Cargo.toml; the macro then selects the matching implementation.
Feature‑Unification Conflict
Rust’s Cargo requires features to be additive. When a crate appears multiple times in a dependency graph, Cargo may unify its features, causing mutually exclusive features (e.g., maybe_async/is_sync and client-reqwest) to clash. This makes it impossible for a single binary to depend on both sync and async versions of the same crate.
References explaining feature unification: https://doc.rust-lang.org/cargo/reference/features.html#feature-unification and the feature‑resolver‑v2 documentation: https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2.
A minimal reproducer repository ( https://github.com/marioortizmanero/resolver-v2-conflict) shows the conflict under any resolver version.
Other Crates Affected
arangorsand aragog (ArangoDB wrappers) use maybe_async and hit the same conflict. inkwell (LLVM wrapper) suffers similar issues. k8s-openapi also encounters the conflict.
Fixing maybe_async
The crate was updated to provide two exclusive feature flags: is_sync and is_async. Functions are generated with suffixes ( _sync / _async) to avoid name collisions:
#[maybe_async::maybe_async]
async fn endpoint() { /* ... */ } #[cfg(feature = "is_async")]
async fn endpoint_async() { /* ... */ }
#[cfg(feature = "is_sync")]
fn endpoint_sync() { /* ... with .await removed */ }Although the suffixes add noise, they resolve the mutual‑exclusion problem without requiring Cargo to support mutually exclusive features.
Conclusion
Three viable strategies were explored:
Duplicate code in a separate blocking module – simple but unsustainable.
Wrap async calls with a shared runtime – reduces duplication but still incurs runtime overhead and feature‑unification issues.
Use the maybe_async procedural macro – eliminates duplication, keeps binary size low, and leverages Cargo feature flags, at the cost of handling mutually exclusive features via suffixes.
Given the trade‑offs, the macro‑based solution is recommended for libraries that need both async and sync APIs while preserving maintainability.
References
Spotify Web API – https://developer.spotify.com/documentation/web-api/
ArangoDB – https://www.arangodb.com/
reqwest crate – https://crates.io/crates/reqwest
reqwest blocking module – https://docs.rs/reqwest/0.11.4/reqwest/blocking/index.html
tokio runtime – https://crates.io/crates/tokio
maybe_async crate – https://crates.io/crates/maybe_async
Cargo feature unification – https://doc.rust-lang.org/cargo/reference/features.html#feature-unification
Feature resolver v2 – https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2
Sans I/O – https://sans-io.readthedocs.io/
tame-oidc example – https://github.com/EmbarkStudios/tame-oidc
Conflict reproducer – https://github.com/marioortizmanero/resolver-v2-conflict
maybe_async issue – https://github.com/fMeow/maybe-async-rs/issues/6
Rust RFC on mutually exclusive features – https://github.com/rust-lang/rfcs/pull/2962
cargo‑hack for testing feature combinations – https://github.com/taiki-e/cargo-hack
Keyword‑generics initiative – https://blog.rust-lang.org/inside-rust/2023/02/23/keyword-generics-progress-report-feb-2023.html
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
BirdNest Tech Talk
Author of the rpcx microservice framework, original book author, and chair of Baidu's Go CMC committee.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
