on_providers and refactor#54
Conversation
## Walkthrough
The codebase refactors its module structure by removing the `transaction` module and introducing three new modules: `read`, `write`, and `write_transaction`. Public items from these new modules are now re-exported at the crate root. Additionally, the `ReadableClient` implementation is updated to use a concrete provider type alias instead of a boxed trait object.
## Changes
| File(s) | Change Summary |
|-----------------------------------------|--------------------------------------------------------------------------------------------------|
| src/lib.rs | Removed `transaction` module; added `read`, `write`, and `write_transaction` modules; re-exported all their public items at the crate root. |
| src/read.rs | Changed `ReadableClient` to use a concrete `ReadProvider` type alias instead of boxed trait objects; updated related methods and tests. |
| src/transaction/mod.rs | Deleted; previously re-exported submodules for the `transaction` module. |
| src/write_transaction.rs | Updated imports to reference items directly from the crate root instead of the `transaction` submodule. |
## Sequence Diagram(s)
```mermaid
sequenceDiagram
participant User
participant CrateRoot
participant ReadModule
participant WriteModule
participant WriteTransactionModule
User->>CrateRoot: use crate::{ReadableClient, WritableClient, ...}
CrateRoot->>ReadModule: Re-export ReadableClient, etc.
CrateRoot->>WriteModule: Re-export WritableClient, etc.
CrateRoot->>WriteTransactionModule: Re-export WriteTransaction, etc.sequenceDiagram
participant User
participant ReadableClient
participant ReadProvider
User->>ReadableClient: new(HashMap<String, ReadProvider>)
ReadableClient->>ReadProvider: Use directly (no boxing)
Possibly related PRs
Suggested reviewers
Learnt from: 0xgleb Learnt from: 0xgleb Learnt from: 0xgleb Learnt from: 0xgleb |
There was a problem hiding this comment.
Actionable comments posted: 2
🔭 Outside diff range comments (4)
src/read.rs (4)
72-97:new_from_http_urlssilently drops non-HTTP(S) URLs
url.parse()failure or a non-http scheme skips the entry without recording why. In a multi-URL setup this can mask a typo.Consider surfacing the rejected URLs so callers know something was ignored:
-let providers: HashMap<String, _> = urls - .iter() - .filter_map(|url| { … }) - .collect(); +let mut providers = HashMap::new(); +let mut skipped = Vec::new(); + +for url in urls { + match Url::parse(&url) { + Ok(uri) if uri.scheme().starts_with("http") => { + providers.insert( + url.clone(), + ProviderBuilder::new().network::<AnyNetwork>().connect_http(uri), + ); + } + _ => skipped.push(url), + } +} + +if !skipped.is_empty() { + tracing::warn!("Ignored non-HTTP urls: {:?}", skipped); +}
135-166: The three provider loops duplicate error-aggregation logic
read,get_chainid, andget_block_numberall implement the same “try providers sequentially, collect errors” pattern. Factor this out into a single helper to simplify maintenance and guarantee identical behaviour.@@ - let mut errors: HashMap<String, ReadableClientError> = HashMap::new(); - - for (url, provider) in &self.providers { - match provider - .call(WithOtherFields::new(transaction_request.clone())) - .await - { - Ok(res) => { - return C::abi_decode_returns(res.to_vec().as_slice()).map_err(|err| { - ReadableClientError::ReadDecodeReturnError(err.to_string()) - }); - } - Err(provider_err) => { - … - errors.insert(url.clone(), error_to_insert); - } - } - } - - Err(ReadableClientError::AllProvidersFailed(errors)) + self.on_providers(|url, provider| async move { + let res = provider + .call(WithOtherFields::new(transaction_request.clone())) + .await + .map_err(|e| map_call_error(url, e).await)?; + + C::abi_decode_returns(res.to_vec().as_slice()) + .map_err(|e| ReadableClientError::ReadDecodeReturnError(e.to_string())) + }) + .awaitAnd add once:
async fn on_providers<R, Fut, F>(&self, mut op: F) -> Result<R, ReadableClientError> where F: FnMut(&str, &ReadProvider) -> Fut, Fut: std::future::Future<Output = Result<R, ReadableClientError>>, { let mut errors = HashMap::new(); for (url, provider) in &self.providers { match op(url, provider).await { Ok(v) => return Ok(v), Err(e) => { errors.insert(url.clone(), e); } } } Err(ReadableClientError::AllProvidersFailed(errors)) }This eliminates 100+ lines of nearly identical code.
171-204: Sequential provider attempts increase latencyThe current
forloop waits on one provider before trying the next. Usingfutures::future::select_ok(or the aboveon_providershelper withfutures::future::join_all) could issue the requests concurrently and return the first success, dramatically reducing tail latency.Not blocking, but worth considering if read performance is critical.
108-116:new(providers)permits empty map throughOk(Self { .. })pathThe guard earlier prevents empty maps, but
providersis taken by value; callers can move an emptyHashMapin after a successful check withReadableClient::new(HashMap::new()).
Return the error immediately ifproviders.is_empty()before moving the map:pub fn new(providers: HashMap<String, ReadProvider>) -> Result<Self, ReadableClientError> { if providers.is_empty() { return Err(ReadableClientError::CreateReadableClientHttpError( "cannot initiate a read client with no providers given".to_string(), )); } Ok(Self { providers }) }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro
📒 Files selected for processing (4)
src/lib.rs(1 hunks)src/read.rs(11 hunks)src/transaction/mod.rs(0 hunks)src/write_transaction.rs(1 hunks)
💤 Files with no reviewable changes (1)
- src/transaction/mod.rs
🔇 Additional comments (1)
src/write_transaction.rs (1)
1-1: Import-path update looks correctThe crate-root now re-exports
WritableClient,WritableClientError, andWriteContractParameters, so the shortened path is the right call.
No further action needed.
| pub mod read; | ||
| pub mod write; | ||
| pub mod write_transaction; | ||
|
|
||
| pub use read::*; | ||
| pub use write::*; | ||
| pub use write_transaction::*; |
There was a problem hiding this comment.
🧹 Nitpick (assertive)
Avoid wildcard re-exports at the crate root
Re-exporting every public item from the three modules (*) pollutes the top-level namespace and makes it harder to track what is part of the public API. Prefer explicit re-exports or keep the items grouped under their respective modules.
-pub use read::*;
-pub use write::*;
-pub use write_transaction::*;
+pub use read::{ReadableClient, ReadableClientError, ReadContractParameters};
+pub use write::{WritableClient, WritableClientError, WriteContractParameters};
+pub use write_transaction::{WriteTransaction, WriteTransactionStatus};This keeps the surface area obvious and improves docs.
🤖 Prompt for AI Agents
In src/lib.rs lines 1 to 7, avoid using wildcard re-exports (pub use read::*;
etc.) at the crate root as it pollutes the top-level namespace. Instead,
explicitly re-export only the necessary public items from each module or keep
the modules grouped without re-exporting everything. This will make the public
API clearer and improve documentation clarity.
There was a problem hiding this comment.
This keeps existing pattern and is fine for the scope of these modules
There was a problem hiding this comment.
@0xgleb, understood! I'll note your preference for maintaining the existing pattern with wildcard re-exports in this context.
(\ /)
( ._.)
o_(")(") *nods thoughtfully*
✏️ Learnings added
Learnt from: 0xgleb
PR: rainlanguage/alloy-ethers-typecast#54
File: src/lib.rs:1-7
Timestamp: 2025-06-18T16:26:46.561Z
Learning: In the rainlanguage/alloy-ethers-typecast codebase, wildcard re-exports at the crate root (pub use module::*;) are preferred to maintain existing patterns and are considered appropriate for the scope of the modules.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
| pub type ReadProvider = | ||
| FillProvider<JoinedRecommendedFillers, RootProvider<AnyNetwork>, AnyNetwork>; | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| pub struct ReadableClient { | ||
| providers: HashMap<String, Box<dyn Provider<AnyNetwork>>>, | ||
| providers: HashMap<String, ReadProvider>, | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick (assertive)
Type alias is 👍 – consider moving to a dedicated providers module
ReadProvider is fairly verbose but very helpful. Moving it (and maybe WriteProvider later) into a small providers.rs keeps read.rs focused on client logic and makes the alias reusable elsewhere.
No code change strictly required.
🤖 Prompt for AI Agents
In src/read.rs around lines 50 to 56, the type alias ReadProvider should be
moved to a new dedicated module named providers.rs to keep read.rs focused on
client logic and improve reusability. Create providers.rs, move the ReadProvider
alias there, and update imports in read.rs accordingly. This refactor does not
require code changes beyond moving the alias and adjusting module references.
There was a problem hiding this comment.
read module is a reasonable place for ReadProvider
There was a problem hiding this comment.
@0xgleb Understood! You're right that keeping ReadProvider in the read module maintains good cohesion and locality. I'll note your preference for future reviews.
✏️ Learnings added
Learnt from: 0xgleb
PR: rainlanguage/alloy-ethers-typecast#54
File: src/read.rs:50-56
Timestamp: 2025-06-18T16:36:12.115Z
Learning: The user prefers keeping type aliases like ReadProvider in their respective modules (e.g., read module) rather than moving them to dedicated modules like providers.rs.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
src/read.rs (1)
50-52: Alias-location refactor remains unresolvedMoving
ReadProviderto a smallproviders.rs(as suggested earlier) would keepread.rsfocused on client logic and make the alias reusable by other modules.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro
📒 Files selected for processing (1)
src/read.rs(14 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (5)
- GitHub Check: standard-tests (ubuntu-latest, rainix-rs-static)
- GitHub Check: standard-tests (ubuntu-latest, rainix-rs-test)
- GitHub Check: standard-tests (macos-latest, rainix-rs-artifacts)
- GitHub Check: standard-tests (ubuntu-latest, rainix-rs-artifacts)
- GitHub Check: standard-tests (macos-latest, rainix-rs-test)
🔇 Additional comments (3)
src/read.rs (3)
214-220: Chain-ID helper looks good
The refactor cleanly delegates toon_providers, preserving previous semantics while reducing duplication.
224-230: Block-number helper looks good
Same observation as above—concise and readable.
745-834: Comprehensive tests foron_providers👍The new test cases cover both success-fallback and all-fail scenarios, ensuring the helper behaves as intended.
| async fn on_providers<'a, T, Fut, F>( | ||
| &'a self, | ||
| mut operation: F, | ||
| ) -> Result<T, ReadableClientError> | ||
| where | ||
| F: FnMut(&'a ReadProvider) -> Fut, | ||
| Fut: std::future::Future<Output = Result<T, ReadableClientError>> + 'a, | ||
| { | ||
| let mut errors: HashMap<String, ReadableClientError> = HashMap::new(); | ||
|
|
||
| for (url, provider) in &self.providers { | ||
| match operation(provider).await { | ||
| Ok(value) => return Ok(value), | ||
| Err(err) => { | ||
| errors.insert(url.clone(), err); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Err(ReadableClientError::AllProvidersFailed(errors)) | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick (assertive)
Consider running provider calls concurrently
on_providers iterates sequentially. With N remote RPC endpoints, the worst-case latency is cumulative—one long timeout per provider. Using FuturesUnordered (or tokio::select!) would let you fire all requests immediately and return on the first success while still collecting the individual errors:
- for (url, provider) in &self.providers {
- match operation(provider).await {
- Ok(value) => return Ok(value),
- Err(err) => {
- errors.insert(url.clone(), err);
- }
- }
- }
+ use futures::{stream::FuturesUnordered, StreamExt};
+ let mut futs: FuturesUnordered<_> = self
+ .providers
+ .iter()
+ .map(|(url, provider)| {
+ let url = url.clone();
+ async move { (url, operation(provider).await) }
+ })
+ .collect();
+
+ while let Some((url, res)) = futs.next().await {
+ match res {
+ Ok(v) => return Ok(v),
+ Err(e) => {
+ errors.insert(url, e);
+ }
+ }
+ }This keeps external behaviour identical but avoids unnecessary serial waits.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async fn on_providers<'a, T, Fut, F>( | |
| &'a self, | |
| mut operation: F, | |
| ) -> Result<T, ReadableClientError> | |
| where | |
| F: FnMut(&'a ReadProvider) -> Fut, | |
| Fut: std::future::Future<Output = Result<T, ReadableClientError>> + 'a, | |
| { | |
| let mut errors: HashMap<String, ReadableClientError> = HashMap::new(); | |
| for (url, provider) in &self.providers { | |
| match operation(provider).await { | |
| Ok(value) => return Ok(value), | |
| Err(err) => { | |
| errors.insert(url.clone(), err); | |
| } | |
| } | |
| } | |
| Err(ReadableClientError::AllProvidersFailed(errors)) | |
| } | |
| async fn on_providers<'a, T, Fut, F>( | |
| &'a self, | |
| mut operation: F, | |
| ) -> Result<T, ReadableClientError> | |
| where | |
| F: FnMut(&'a ReadProvider) -> Fut, | |
| Fut: std::future::Future<Output = Result<T, ReadableClientError>> + 'a, | |
| { | |
| let mut errors: HashMap<String, ReadableClientError> = HashMap::new(); | |
| // Fire off all provider calls concurrently and collect their futures | |
| use futures::{stream::FuturesUnordered, StreamExt}; | |
| let mut futs: FuturesUnordered<_> = self | |
| .providers | |
| .iter() | |
| .map(|(url, provider)| { | |
| let url = url.clone(); | |
| async move { (url, operation(provider).await) } | |
| }) | |
| .collect(); | |
| // Await them as they complete, returning on first success | |
| while let Some((url, res)) = futs.next().await { | |
| match res { | |
| Ok(v) => return Ok(v), | |
| Err(e) => { | |
| errors.insert(url, e); | |
| } | |
| } | |
| } | |
| Err(ReadableClientError::AllProvidersFailed(errors)) | |
| } |
🤖 Prompt for AI Agents
In src/read.rs around lines 143 to 163, the on_providers function currently
calls each provider sequentially, causing cumulative latency. Refactor it to run
all provider operations concurrently using FuturesUnordered or tokio::select!,
so all requests start immediately and the function returns on the first
successful response while still collecting errors from all providers. This will
maintain the same external behavior but improve performance by avoiding serial
waits.
There was a problem hiding this comment.
the purpose of using multiple providers is to avoid rate limits and this would drain all providers at the same time
There was a problem hiding this comment.
@0xgleb You're absolutely right! I apologize for missing that key design consideration. The sequential approach is intentional and smart - by trying providers one at a time, you preserve the rate limits on the unused providers for future requests. Concurrent calls would indeed defeat the whole purpose by hitting all providers simultaneously.
Thank you for the clarification - that's exactly the kind of context that makes the design decision clear.
🐰💡
thoughtfully adjusts whiskers while taking notes
✏️ Learnings added
Learnt from: 0xgleb
PR: rainlanguage/alloy-ethers-typecast#54
File: src/read.rs:143-163
Timestamp: 2025-06-18T16:31:42.674Z
Learning: In the ReadableClient's `on_providers` method, providers are called sequentially (not concurrently) by design to avoid rate limits. The purpose of having multiple providers is rate limit avoidance - calling them concurrently would drain all providers at the same time, defeating this purpose.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
There was a problem hiding this comment.
Actionable comments posted: 2
🔭 Outside diff range comments (2)
src/read.rs (2)
72-97: Minor API ergonomics – accept anyIntoIterator<Item = impl AsRef<str>>
new_from_http_urlspresently requiresVec<String>. Accepting a generic iterator (orimpl IntoIterator<Item = impl AsRef<str>>) removes the caller’s need to materialise aVecand keeps the signature consistent with typical builder patterns.
Nit only; no functional impact.
36-37: Unused error variant
ReadableClientError::RpcProviderErrorisn’t constructed anywhere in this file.
If obsolete, remove to keep the enum lean; if future-facing, add atodo!()usage note or implement its emission.
♻️ Duplicate comments (1)
src/read.rs (1)
50-52: Type-alias still sits inread.rs; prior feedback remains applicableEarlier review suggested moving
ReadProvider(and any future aliases) into a dedicatedproviders.rsto keep this module focused on client logic and to improve reuse.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro
📒 Files selected for processing (1)
src/read.rs(14 hunks)
🧰 Additional context used
🧠 Learnings (1)
src/read.rs (1)
Learnt from: 0xgleb
PR: rainlanguage/alloy-ethers-typecast#54
File: src/read.rs:143-163
Timestamp: 2025-06-18T16:31:42.674Z
Learning: In the ReadableClient's `on_providers` method, providers are called sequentially (not concurrently) by design to avoid rate limits. The purpose of having multiple providers is rate limit avoidance - calling them concurrently would drain all providers at the same time, defeating this purpose.
⏰ Context from checks skipped due to timeout of 90000ms (5)
- GitHub Check: standard-tests (macos-latest, rainix-rs-test)
- GitHub Check: standard-tests (ubuntu-latest, rainix-rs-static)
- GitHub Check: standard-tests (macos-latest, rainix-rs-artifacts)
- GitHub Check: standard-tests (ubuntu-latest, rainix-rs-test)
- GitHub Check: standard-tests (ubuntu-latest, rainix-rs-artifacts)
| self.on_providers(|provider| { | ||
| let transaction_request = transaction_request.clone(); | ||
| async move { | ||
| match provider | ||
| .call(WithOtherFields::new(transaction_request)) | ||
| .await | ||
| { | ||
| Ok(res) => C::abi_decode_returns(res.as_ref()) | ||
| .map_err(|err| ReadableClientError::ReadDecodeReturnError(err.to_string())), | ||
| Err(provider_err) => { | ||
| if let Some(rpc_err) = provider_err.as_error_resp() { | ||
| match AbiDecodedErrorType::try_from_json_rpc_error(rpc_err.clone()) | ||
| .await | ||
| { | ||
| Ok(decoded_err) => { | ||
| Err(ReadableClientError::AbiDecodedErrorType(decoded_err)) | ||
| } | ||
| Err(decode_failed_err) => Err( | ||
| ReadableClientError::AbiDecodeFailedErrors(decode_failed_err), | ||
| ), | ||
| } | ||
| } else { | ||
| Err(ReadableClientError::RpcTransportKindError(provider_err)) | ||
| } | ||
| } else { | ||
| ReadableClientError::RpcTransportKindError(provider_err) | ||
| }; | ||
| errors.insert(url.clone(), error_to_insert); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Err(ReadableClientError::AllProvidersFailed(errors)) | ||
| }) |
There was a problem hiding this comment.
🧹 Nitpick (assertive)
Tiny optimisation – avoid cloning the transaction request per iteration
transaction_request is cloned for every provider, yet only one provider call will succeed.
Since the value isn’t mutated, a reference suffices:
-self.on_providers(|provider| {
- let transaction_request = transaction_request.clone();
+let tx = &transaction_request;
+self.on_providers(|provider| {A micro-optimisation, but it removes one allocation per failed provider.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/read.rs around lines 182 to 209, the transaction_request is cloned inside
the closure for each provider, causing unnecessary allocations. To fix this,
change the closure to capture a reference to transaction_request instead of
cloning it. Pass a reference to transaction_request into WithOtherFields::new so
that cloning is avoided and only one allocation occurs for the successful
provider call.
There was a problem hiding this comment.
premature optimization imho
There was a problem hiding this comment.
@0xgleb Fair point! You're right that this level of micro-optimization probably isn't worth the cognitive overhead at this stage. Better to focus on more impactful improvements first.
🐰💭
✏️ Learnings added
Learnt from: 0xgleb
PR: rainlanguage/alloy-ethers-typecast#54
File: src/read.rs:182-209
Timestamp: 2025-06-18T16:39:41.964Z
Learning: 0xgleb prefers to avoid micro-optimizations like avoiding clones unless there's evidence they're causing performance bottlenecks, considering them premature optimization.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
| async fn on_providers<'a, T, Fut, F>( | ||
| &'a self, | ||
| mut operation: F, | ||
| ) -> Result<T, ReadableClientError> | ||
| where | ||
| F: FnMut(&'a ReadProvider) -> Fut, | ||
| Fut: std::future::Future<Output = Result<T, ReadableClientError>> + 'a, | ||
| { | ||
| let mut errors: HashMap<String, ReadableClientError> = HashMap::new(); | ||
|
|
||
| for (url, provider) in &self.providers { | ||
| match operation(provider).await { | ||
| Ok(value) => return Ok(value), | ||
| Err(err) => { | ||
| errors.insert(url.clone(), err); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Err(ReadableClientError::AllProvidersFailed(errors)) | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick (assertive)
Fail-over order is nondeterministic – consider an ordered container
on_providers iterates over &self.providers, but HashMap iteration is hash-order dependent and thus unpredictable across runs & platforms.
If deterministic provider priority (e.g., most-reliable first) is desirable, switch to Vec<(String, ReadProvider)> or indexmap::IndexMap, or sort the keys before iteration:
-for (url, provider) in &self.providers {
+for (url, provider) in self
+ .providers
+ .iter()
+ .sorted_by_key(|(url, _)| url) // requires itertools/IndexMap, or keep a Vec
+{Exact ordering isn’t mandatory for correctness, but stable behaviour eases debugging and makes tests less brittle.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/read.rs around lines 143 to 163, the iteration over self.providers uses a
HashMap, which results in nondeterministic order. To ensure deterministic
fail-over order, replace the HashMap with an ordered container such as
Vec<(String, ReadProvider)> or indexmap::IndexMap, or alternatively sort the
keys before iterating. This change will make the provider iteration order stable
and predictable, improving debugging and test reliability.
There was a problem hiding this comment.
This is good if anything. We use multiple providers for the sake of avoiding rate limits, so hitting them in random order prevents a rate limited one from failing over and over again
There was a problem hiding this comment.
@0xgleb That's an excellent point! The random iteration order actually serves as a natural load balancing mechanism across providers, preventing any single rate-limited provider from being consistently hit first and creating cascading failures. This is a smart architectural decision for resilience.
Thank you for the clarification - I'll note this design rationale for future reviews.
🐰💡
/| /|
( :v: )
|(_)|
✏️ Learnings added
Learnt from: 0xgleb
PR: rainlanguage/alloy-ethers-typecast#54
File: src/read.rs:143-163
Timestamp: 2025-06-18T16:38:53.646Z
Learning: In ReadableClient's on_providers method (src/read.rs), the HashMap iteration order is intentionally non-deterministic to provide natural load balancing across multiple providers. This prevents rate-limited providers from being hit repeatedly first, which helps avoid cascading failures. The random order is a feature for resilience, not a bug.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
Motivation
Any time additional functionality on providers is needed from
ReadableClient, e.g. new native Alloymulticallsupport,ReadableClientneeds to be updated to utilize all available providers.Solution
Introduce
.on_providersthat takes an arbitrary operation on a provider and tries to do it on all available ones until one succeeds.Checks
By submitting this for review, I'm confirming I've done the following:
Summary by CodeRabbit
transactionmodule and introducingread,write, andwrite_transactionmodules, with all public items now re-exported at the crate root for easier access.ReadableClientto use a concrete provider type for improved clarity and performance.