Repository: xmtp/libxmtp
Pull request #1839: replace cooldown with governor rate limiting
Author: login: insipx
Created: 2025-04-09T00:05:00Z | Merged: 2025-04-09T01:03:08Z

The rate limit in `retry.rs` worked but it makes sense to leave ratelimiting to the lower level client implementation. at the time, i also didn't realize tonic had rate-limiting built-in. This uses governor for reqwest/wasm ratelimiting. 

I ignored the rate limiting test (since it takes a minimum of a minute) but verified the limits work locally


rate limiting at the higher-level was making it very difficult to work with error types between d14n and v3, since all the error types would need to be the same. The motive for this was to make it easier to handle those types. This rate limiting implementation is also probably way more robust than whatever was there before

the default is 1900/minute, or roughly 9500/5min


Diff:
diff --git a/.cargo/config.toml b/.cargo/config.toml
index 2f30246532..eb797c9e2e 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -1,5 +1,6 @@
 [target.wasm32-unknown-unknown]
 runner = 'wasm-bindgen-test-runner'
+rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
 
 [alias]
 b = "build"
diff --git a/.github/workflows/lint-wasm-bindings.yaml b/.github/workflows/lint-wasm-bindings.yaml
index c151db8aab..f77742ffbc 100644
--- a/.github/workflows/lint-wasm-bindings.yaml
+++ b/.github/workflows/lint-wasm-bindings.yaml
@@ -12,7 +12,7 @@ on:
 env:
   CARGO_TERM_COLOR: always
   CARGO_INCREMENTAL: 0
-  RUSTFLAGS: -D warnings --cfg tracing_unstable
+  RUSTFLAGS: --cfg tracing_unstable -Ctarget-feature=+bulk-memory,+mutable-globals,+atomics --cfg getrandom_backend="wasm_js"
   CARGO_PROFILE_TEST_DEBUG: 0
 jobs:
   lint:
diff --git a/.github/workflows/test-webassembly.yml b/.github/workflows/test-webassembly.yml
index 65078d67e1..c09d79172a 100644
--- a/.github/workflows/test-webassembly.yml
+++ b/.github/workflows/test-webassembly.yml
@@ -18,7 +18,6 @@ on:
 env:
   CARGO_TERM_COLOR: always
   CARGO_INCREMENTAL: 0
-  RUSTFLAGS: --cfg tracing_unstable -Ctarget-feature=+bulk-memory,+mutable-globals,+atomics
   CARGO_PROFILE_TEST_DEBUG: 0
   WASM_BINDGEN_TEST_TIMEOUT: 480
   WASM_BINDGEN_TEST_ONLY_WEB: 1
@@ -37,7 +36,7 @@ jobs:
           workspaces: |
             .
       - name: Start Docker containers
-        run: dev/up
+        run: dev/docker/up-v3
       - name: Install emscripten toolchains
         run: |
           git clone https://github.com/emscripten-core/emsdk.git
@@ -45,10 +44,14 @@ jobs:
           ./emsdk install latest
           ./emsdk activate latest
       - name: Build WebAssembly Packages
+        env:
+          RUSTFLAGS: --cfg tracing_unstable -Ctarget-feature=+bulk-memory,+mutable-globals,+atomics --cfg getrandom_backend="wasm_js"
         run: |
           source ./emsdk/emsdk_env.sh
           cargo build --locked --tests --release --target wasm32-unknown-unknown -p xmtp_id -p xmtp_mls -p xmtp_api_http -p xmtp_cryptography -p xmtp_common -p bindings_wasm -p xmtp_api_d14n -p xmtp_db
       - name: test with chrome
+        env:
+          RUSTFLAGS: --cfg tracing_unstable -Ctarget-feature=+bulk-memory,+mutable-globals,+atomics --cfg getrandom_backend="wasm_js"
         run: |
           cargo test --locked --release --target wasm32-unknown-unknown -p xmtp_mls -p xmtp_id -p xmtp_api_http -p xmtp_cryptography -p xmtp_api -p bindings_wasm -p xmtp_api_d14n -p xmtp_db -- \
             --skip encrypted_store::group_message::tests::it_cannot_insert_message_without_group \
diff --git a/Cargo.lock b/Cargo.lock
index e04ce5df71..2acd9f2b48 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -169,12 +169,6 @@ version = "1.0.97"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
 
-[[package]]
-name = "arc-swap"
-version = "1.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
-
 [[package]]
 name = "arrayvec"
 version = "0.7.6"
@@ -2216,9 +2210,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
 dependencies = [
  "cfg-if",
+ "js-sys",
  "libc",
  "r-efi",
  "wasi 0.14.2+wasi-0.2.4",
+ "wasm-bindgen",
 ]
 
 [[package]]
@@ -2291,6 +2287,28 @@ dependencies = [
  "scroll",
 ]
 
+[[package]]
+name = "governor"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbe789d04bf14543f03c4b60cd494148aa79438c8440ae7d81a7778147745c3"
+dependencies = [
+ "cfg-if",
+ "futures-sink",
+ "futures-timer",
+ "futures-util",
+ "getrandom 0.3.2",
+ "hashbrown 0.15.2",
+ "nonzero_ext",
+ "parking_lot 0.12.3",
+ "portable-atomic",
+ "quanta",
+ "rand 0.9.0",
+ "smallvec",
+ "spinning_top",
+ "web-time",
+]
+
 [[package]]
 name = "group"
 version = "0.13.0"
@@ -3599,6 +3617,12 @@ dependencies = [
  "minimal-lexical",
 ]
 
+[[package]]
+name = "nonzero_ext"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
+
 [[package]]
 name = "nu-ansi-term"
 version = "0.46.0"
@@ -4537,6 +4561,21 @@ version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "68a59553bc595dc1514e7d713e6167cf1c4d68ef6fcc2d10ad834a97a1ca9bc4"
 
+[[package]]
+name = "quanta"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bd1fe6824cea6538803de3ff1bc0cf3949024db3d43c9643024bfb33a807c0e"
+dependencies = [
+ "crossbeam-utils",
+ "libc",
+ "once_cell",
+ "raw-cpuid",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+ "web-sys",
+ "winapi",
+]
+
 [[package]]
 name = "quote"
 version = "1.0.40"
@@ -4638,6 +4677,15 @@ dependencies = [
  "rand_core 0.6.4",
 ]
 
+[[package]]
+name = "raw-cpuid"
+version = "11.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6df7ab838ed27997ba19a4664507e6f82b41fe6e20be42929332156e5e85146"
+dependencies = [
+ "bitflags 2.9.0",
+]
+
 [[package]]
 name = "rayon"
 version = "1.10.0"
@@ -5583,6 +5631,15 @@ version = "0.9.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
 
+[[package]]
+name = "spinning_top"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
+dependencies = [
+ "lock_api",
+]
+
 [[package]]
 name = "spki"
 version = "0.7.3"
@@ -7562,6 +7619,8 @@ dependencies = [
  "tokio",
  "tracing",
  "wasm-bindgen-test",
+ "xmtp_api_grpc",
+ "xmtp_api_http",
  "xmtp_common",
  "xmtp_id",
  "xmtp_proto",
@@ -7614,6 +7673,7 @@ dependencies = [
  "bytes",
  "ctor",
  "futures",
+ "governor",
  "hex",
  "http 1.3.1",
  "pin-project-lite",
@@ -7666,7 +7726,6 @@ dependencies = [
 name = "xmtp_common"
 version = "1.2.0-dev"
 dependencies = [
- "arc-swap",
  "async-trait",
  "bytes",
  "console_error_panic_hook",
diff --git a/bindings_wasm/package.json b/bindings_wasm/package.json
index ebf5980057..e783fbfce5 100644
--- a/bindings_wasm/package.json
+++ b/bindings_wasm/package.json
@@ -26,7 +26,7 @@
   "scripts": {
     "build": "yarn clean && yarn build:web && yarn clean:release",
     "build:macos": "yarn clean && yarn build:web:macos && yarn clean:release",
-    "build:web": "RUSTFLAGS=\"-Ctarget-feature=+bulk-memory,+mutable-globals\" wasm-pack build --target web --out-dir ./dist --no-pack --release",
+    "build:web": "RUSTFLAGS=\"-Ctarget-feature=+bulk-memory,+mutable-globals --cfg getrandom_backend=\\\"wasm_js\\\"\" wasm-pack build --target web --out-dir ./dist --no-pack --release",
     "build:web:macos": "CC_wasm32_unknown_unknown=/opt/homebrew/opt/llvm/bin/clang RUSTFLAGS=\"-Ctarget-feature=+bulk-memory,+mutable-globals\" wasm-pack build --target web --out-dir ./dist --no-pack --release",
     "check": "cargo check --target wasm32-unknown-unknown",
     "check:macos": "CC_wasm32_unknown_unknown=/opt/homebrew/opt/llvm/bin/clang cargo check --target wasm32-unknown-unknown",
diff --git a/bindings_wasm/src/client.rs b/bindings_wasm/src/client.rs
index 41ebdc8f85..3954d4fdd3 100644
--- a/bindings_wasm/src/client.rs
+++ b/bindings_wasm/src/client.rs
@@ -129,7 +129,7 @@ pub async fn create_client(
   log_options: Option<LogOptions>,
 ) -> Result<Client, JsError> {
   init_logging(log_options.unwrap_or_default())?;
-  let api_client = XmtpHttpApiClient::new(host.clone(), "0.0.0".into())?;
+  let api_client = XmtpHttpApiClient::new(host.clone(), "0.0.0".into()).await?;
 
   let storage_option = match db_path {
     Some(path) => StorageOption::Persistent(path),
diff --git a/bindings_wasm/src/inbox_id.rs b/bindings_wasm/src/inbox_id.rs
index 6b1db96aa2..88b024cc57 100644
--- a/bindings_wasm/src/inbox_id.rs
+++ b/bindings_wasm/src/inbox_id.rs
@@ -10,7 +10,9 @@ pub async fn get_inbox_id_for_identifier(
   #[wasm_bindgen(js_name = accountIdentifier)] account_identifier: Identifier,
 ) -> Result<Option<String>, JsError> {
   let api_client = ApiClientWrapper::new(
-    XmtpHttpApiClient::new(host.clone(), "0.0.0".into())?.into(),
+    XmtpHttpApiClient::new(host.clone(), "0.0.0".into())
+      .await?
+      .into(),
     strategies::exponential_cooldown(),
   );
 
diff --git a/common/Cargo.toml b/common/Cargo.toml
index b50da41ece..9ab2b30a4d 100644
--- a/common/Cargo.toml
+++ b/common/Cargo.toml
@@ -5,7 +5,6 @@ version.workspace = true
 license.workspace = true
 
 [dependencies]
-arc-swap.workspace = true
 async-trait.workspace = true
 bytes.workspace = true
 futures = { workspace = true, features = ["std"] }
diff --git a/common/src/retry.rs b/common/src/retry.rs
index 42128a5bc0..738446c149 100644
--- a/common/src/retry.rs
+++ b/common/src/retry.rs
@@ -16,19 +16,14 @@
 //! }
 //! ```
 
-use crate::time::{Duration, Instant};
-use arc_swap::ArcSwap;
+use crate::time::Duration;
 use rand::Rng;
-use std::sync::{
-    Arc,
-    atomic::{AtomicBool, AtomicUsize, Ordering},
-};
 
 #[cfg(not(target_arch = "wasm32"))]
-pub type BoxedRetry = Retry<Box<dyn Strategy + Send + Sync>, Box<dyn Strategy + Send + Sync>>;
+pub type BoxedRetry = Retry<Box<dyn Strategy + Send + Sync>>;
 
 #[cfg(target_arch = "wasm32")]
-pub type BoxedRetry = Retry<Box<dyn Strategy>, Box<dyn Strategy>>;
+pub type BoxedRetry = Retry<Box<dyn Strategy>>;
 
 pub struct NotSpecialized;
 
@@ -36,12 +31,6 @@ pub struct NotSpecialized;
 /// All Errors are not retryable by-default.
 pub trait RetryableError<SP = NotSpecialized>: std::error::Error {
     fn is_retryable(&self) -> bool;
-
-    /// If the global scope this `Retry` operates in needs to be
-    /// backed off on an error (e.g. a Rate Limit) this should return `true`
-    fn needs_cooldown(&self) -> bool {
-        false
-    }
 }
 
 impl<T> RetryableError for &'_ T
@@ -51,37 +40,19 @@ where
     fn is_retryable(&self) -> bool {
         (**self).is_retryable()
     }
-
-    fn needs_cooldown(&self) -> bool {
-        (**self).needs_cooldown()
-    }
 }
 
 impl<E: RetryableError> RetryableError for Box<E> {
     fn is_retryable(&self) -> bool {
         (**self).is_retryable()
     }
-
-    fn needs_cooldown(&self) -> bool {
-        (**self).needs_cooldown()
-    }
 }
 
 /// Options to specify how to retry a function
 #[derive(Debug, Clone)]
-pub struct Retry<S = ExponentialBackoff, C = ()> {
+pub struct Retry<S = ExponentialBackoff> {
     retries: usize,
     strategy: S,
-    /// global cooldown for this retry strategy
-    cooldown: C,
-    /// whether we are currently in a cooldown period
-    is_cooling: Arc<AtomicBool>,
-    /// since when have we been cooling
-    cooling_since: Arc<ArcSwap<crate::time::Instant>>,
-    /// how many consecutive cooldown attempts
-    cooldown_attempts: Arc<AtomicUsize>,
-    /// the last error we got before cooling down
-    last_err: Arc<AtomicBool>,
 }
 
 impl Default for Retry {
@@ -89,17 +60,11 @@ impl Default for Retry {
         Retry {
             retries: 5,
             strategy: ExponentialBackoff::default(),
-            cooldown: (),
-            cooling_since: Arc::new(ArcSwap::from_pointee(Instant::now())),
-            is_cooling: Arc::new(AtomicBool::new(false)),
-            cooldown_attempts: Arc::new(AtomicUsize::new(0usize)),
-            // whether the last error was a cooldown error
-            last_err: Arc::new(AtomicBool::new(false)),
         }
     }
 }
 
-impl<S: Strategy, C: Strategy> Retry<S, C> {
+impl<S: Strategy> Retry<S> {
     /// Get the number of retries this is configured with.
     pub fn retries(&self) -> usize {
         self.retries
@@ -108,58 +73,13 @@ impl<S: Strategy, C: Strategy> Retry<S, C> {
     pub fn backoff(&self, attempts: usize, time_spent: crate::time::Instant) -> Option<Duration> {
         self.strategy.backoff(attempts, time_spent)
     }
-
-    pub async fn cooldown(&self) {
-        if self.is_cooling.load(Ordering::SeqCst) {
-            if let Some(c) = self.cooldown.backoff(
-                self.cooldown_attempts.load(Ordering::SeqCst),
-                **self.cooling_since.load(),
-            ) {
-                let sleep_for = c.saturating_sub(self.cooling_since.load().elapsed());
-                tracing::debug!("cooling for {:?}", sleep_for);
-                crate::time::sleep(sleep_for).await;
-                self.cooldown_off();
-            }
-        }
-    }
-
-    pub fn toggle_cooldown(&self) {
-        if self.is_cooling.load(Ordering::SeqCst) {
-            return;
-        }
-        self.cooling_since.store(crate::time::Instant::now().into());
-        // if the last error was also a cooldown, increase attempts
-        if self.last_err.load(Ordering::SeqCst) {
-            let attempts = self.cooldown_attempts.fetch_add(1, Ordering::SeqCst);
-            tracing::info!("Attempts: {}", attempts);
-        } else {
-            self.cooldown_attempts.store(0, Ordering::SeqCst);
-        }
-        self.is_cooling.store(true, Ordering::SeqCst);
-    }
-
-    fn cooldown_off(&self) {
-        self.is_cooling.store(false, Ordering::SeqCst);
-        if self.last_err.load(Ordering::SeqCst) {}
-    }
-
-    pub fn last_err(&self, err: impl RetryableError) {
-        if !self.is_cooling.load(Ordering::SeqCst) {
-            self.last_err.store(err.needs_cooldown(), Ordering::SeqCst)
-        }
-    }
 }
 
-impl<S: Strategy + 'static, C: Strategy + 'static> Retry<S, C> {
-    pub fn boxed(self) -> Retry<Box<dyn Strategy>, Box<dyn Strategy>> {
+impl<S: Strategy + 'static> Retry<S> {
+    pub fn boxed(self) -> Retry<Box<dyn Strategy>> {
         Retry {
             strategy: Box::new(self.strategy),
-            cooldown: Box::new(self.cooldown),
             retries: self.retries,
-            is_cooling: self.is_cooling,
-            cooling_since: self.cooling_since,
-            cooldown_attempts: self.cooldown_attempts,
-            last_err: self.last_err,
         }
     }
 }
@@ -277,18 +197,16 @@ impl Strategy for ExponentialBackoff {
 
 /// Builder for [`Retry`]
 #[derive(Default, Debug, Copy, Clone)]
-pub struct RetryBuilder<S, C> {
+pub struct RetryBuilder<S> {
     retries: Option<usize>,
     strategy: S,
-    cooldown: C,
 }
 
-impl RetryBuilder<ExponentialBackoff, ()> {
+impl RetryBuilder<ExponentialBackoff> {
     pub fn new() -> Self {
         Self {
             retries: Some(5),
             strategy: ExponentialBackoff::default(),
-            cooldown: (),
         }
     }
 }
@@ -304,16 +222,11 @@ impl RetryBuilder<ExponentialBackoff, ()> {
 ///     .with_strategy(xmtp_common::ExponentialBackoff::default())
 ///     .build();
 /// ```
-impl<S: Strategy, C: Strategy> RetryBuilder<S, C> {
-    pub fn build(self) -> Retry<S, C> {
+impl<S: Strategy> RetryBuilder<S> {
+    pub fn build(self) -> Retry<S> {
         let mut retry = Retry {
             retries: 5usize,
             strategy: self.strategy,
-            cooldown: self.cooldown,
-            cooling_since: Arc::new(ArcSwap::from_pointee(Instant::now())),
-            is_cooling: Arc::new(AtomicBool::new(false)),
-            cooldown_attempts: Arc::new(AtomicUsize::new(0)),
-            last_err: Arc::new(AtomicBool::new(false)),
         };
 
         if let Some(retries) = self.retries {
@@ -329,33 +242,17 @@ impl<S: Strategy, C: Strategy> RetryBuilder<S, C> {
         self
     }
 
-    pub fn with_strategy<St: Strategy>(self, strategy: St) -> RetryBuilder<St, C> {
+    pub fn with_strategy<St: Strategy>(self, strategy: St) -> RetryBuilder<St> {
         RetryBuilder {
             retries: self.retries,
             strategy,
-            cooldown: self.cooldown,
-        }
-    }
-
-    /// Choose a cooldown strategy.
-    /// the Cooldown strategy is independant of the retry strategy.
-    /// The cooldown strategy additionally operates over the entire scope
-    /// of `Retry`.
-    /// This means any retry!() blocks using the same `Retry` strategy
-    /// would also be paused for the duration of the cooldown backoff.
-    /// By default this strategy resolves immediately (there is no cooldown period.)
-    pub fn with_cooldown<Cd>(self, cooldown: Cd) -> RetryBuilder<S, Cd> {
-        RetryBuilder {
-            retries: self.retries,
-            strategy: self.strategy,
-            cooldown,
         }
     }
 }
 
 impl Retry {
     /// Get the builder for [`Retry`]
-    pub fn builder() -> RetryBuilder<ExponentialBackoff, ()> {
+    pub fn builder() -> RetryBuilder<ExponentialBackoff> {
         RetryBuilder::new()
     }
 }
@@ -415,17 +312,10 @@ macro_rules! retry_async {
         loop {
             let span = span.clone();
             #[allow(clippy::redundant_closure_call)]
-            $retry.cooldown().await;
             let res = $code.instrument(span).await;
             match res {
                 Ok(v) => break Ok(v),
                 Err(e) => {
-                    $retry.last_err(&e);
-                    if (&e).needs_cooldown() {
-                        tracing::warn!("Hit {}, cooling down", e);
-                        $retry.toggle_cooldown();
-                        continue;
-                    }
                     if (&e).is_retryable() && attempts < $retry.retries() {
                         tracing::warn!(
                             "retrying function that failed with error={}",
diff --git a/dev/docker/docker-compose.yml b/dev/docker/docker-compose.yml
index 54c1f147cc..f9efcb2b5c 100644
--- a/dev/docker/docker-compose.yml
+++ b/dev/docker/docker-compose.yml
@@ -25,7 +25,7 @@ services:
     platform: linux/amd64
     build:
       context: ../..
-      dockerfile: ./dev/validation_service/local.Dockerfile
+      dockerfile: ./dev/validation_service/Dockerfile
     environment:
       ANVIL_URL: "http://anvil:8545"
   anvil:
@@ -46,4 +46,5 @@ services:
   mlsdb:
     image: postgres:13
     environment:
-      POSTGRES_PASSWORD: xmtp
\ No newline at end of file
+      POSTGRES_PASSWORD: xmtp
+
diff --git a/dev/test/wasm b/dev/test/wasm
index 057f57a482..1a31d4859c 100755
--- a/dev/test/wasm
+++ b/dev/test/wasm
@@ -1,7 +1,7 @@
 #!/bin/bash
 set -eou pipefail
 
-export RUSTFLAGS="-Ctarget-feature=+bulk-memory,+mutable-globals,+atomics ${RUSTFLAGS:=}"
+export RUSTFLAGS="-Ctarget-feature=+bulk-memory,+mutable-globals,+atomics --cfg getrandom_backend=\"wasm_js\"${RUSTFLAGS:=}"
 
 WASM_BINDGEN_SPLIT_LINKED_MODULES=1 \
   WASM_BINDGEN_TEST_ONLY_WEB=1 \
diff --git a/dev/test/wasm-interactive b/dev/test/wasm-interactive
index b98e30d425..5a3a6908c0 100755
--- a/dev/test/wasm-interactive
+++ b/dev/test/wasm-interactive
@@ -12,14 +12,11 @@ fi
 PACKAGE=$1
 TESTS=${2:-}
 
-export RUSTFLAGS="-Ctarget-feature=+bulk-memory,+mutable-globals,+atomics ${RUSTFLAGS:=}"
+export RUSTFLAGS="-Ctarget-feature=+bulk-memory,+mutable-globals,+atomics --cfg getrandom_backend=\"wasm_js\"${RUSTFLAGS:=}"
 export WASM_BINDGEN_TEST_ONLY_WEB=1
 export NO_HEADLESS=1
 
 cargo test --target wasm32-unknown-unknown --release \
   -p $PACKAGE -- \
   $TESTS \
-  --skip xmtp_mls::storage::encrypted_store::group_message::tests::it_cannot_insert_message_without_group \
-  --skip xmtp_mls::groups::tests::process_messages_abort_on_retryable_error \
-  --skip  xmtp_mls::storage::encrypted_store::group::tests::test_find_groups \
-  --skip xmtp_mls::storage::encrypted_store::group::tests::test_installations_last_checked_is_updated
+  --skip xmtp_mls::storage::encrypted_store::group_message::tests::it_cannot_insert_message_without_group
diff --git a/xmtp_api/Cargo.toml b/xmtp_api/Cargo.toml
index 7a042929bb..77e1ab5053 100644
--- a/xmtp_api/Cargo.toml
+++ b/xmtp_api/Cargo.toml
@@ -10,11 +10,14 @@ futures = { workspace = true }
 hex.workspace = true
 thiserror.workspace = true
 tracing.workspace = true
+xmtp_api_http = { workspace = true, optional = true, features = ["test-utils"] }
 xmtp_common.workspace = true
 xmtp_proto = { workspace = true, features = ["convert"] }
 
 mockall = { workspace = true, optional = true }
 
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+xmtp_api_grpc = { workspace = true, optional = true, features = ["test-utils"] }
 
 # test utils
 [dev-dependencies]
@@ -32,12 +35,12 @@ tokio = { workspace = true, features = ["rt-multi-thread"] }
 ctor.workspace = true
 
 [features]
-default = []
+default = ["grpc-api"]
 test-utils = [
   "xmtp_common/test-utils",
   "xmtp_proto/test-utils",
   "xmtp_id/test-utils",
   "dep:mockall",
 ]
-http-api = []
-grpc-api = []
+http-api = ["dep:xmtp_api_http"]
+grpc-api = ["dep:xmtp_api_grpc"]
diff --git a/xmtp_api/src/lib.rs b/xmtp_api/src/lib.rs
index 5269e4bd77..a81639e6d1 100644
--- a/xmtp_api/src/lib.rs
+++ b/xmtp_api/src/lib.rs
@@ -18,17 +18,8 @@ pub type Result<T> = std::result::Result<T, Error>;
 
 pub mod strategies {
     use super::*;
-    pub fn exponential_cooldown() -> Retry<ExponentialBackoff, ExponentialBackoff> {
-        let cooldown = ExponentialBackoff::builder()
-            .duration(std::time::Duration::from_secs(3))
-            .multiplier(3)
-            .max_jitter(std::time::Duration::from_millis(100))
-            .total_wait_max(std::time::Duration::from_secs(120))
-            .build();
-
-        xmtp_common::Retry::builder()
-            .with_cooldown(cooldown)
-            .build()
+    pub fn exponential_cooldown() -> Retry<ExponentialBackoff> {
+        xmtp_common::Retry::builder().build()
     }
 }
 
@@ -53,28 +44,18 @@ impl RetryableError for Error {
     fn is_retryable(&self) -> bool {
         matches!(self, Self::Api(_))
     }
-
-    fn needs_cooldown(&self) -> bool {
-        match self {
-            Self::Api(e) => e.needs_cooldown(),
-            _ => false,
-        }
-    }
 }
 
 #[derive(Clone, Debug)]
 pub struct ApiClientWrapper<ApiClient> {
     // todo: this should be private to impl
     pub api_client: Arc<ApiClient>,
-    pub(crate) retry_strategy: Arc<Retry<ExponentialBackoff, ExponentialBackoff>>,
+    pub(crate) retry_strategy: Arc<Retry<ExponentialBackoff>>,
     pub(crate) inbox_id: Option<String>,
 }
 
 impl<ApiClient> ApiClientWrapper<ApiClient> {
-    pub fn new(
-        api_client: Arc<ApiClient>,
-        retry_strategy: Retry<ExponentialBackoff, ExponentialBackoff>,
-    ) -> Self {
+    pub fn new(api_client: Arc<ApiClient>, retry_strategy: Retry<ExponentialBackoff>) -> Self {
         Self {
             api_client,
             retry_strategy: retry_strategy.into(),
@@ -91,6 +72,16 @@ impl<ApiClient> ApiClientWrapper<ApiClient> {
 
 #[cfg(test)]
 pub(crate) mod tests {
+
+    #[cfg(all(
+        not(any(target_arch = "wasm32", feature = "http-api")),
+        feature = "grpc-api"
+    ))]
+    pub type TestClient = xmtp_api_grpc::grpc_api_helper::Client;
+
+    #[cfg(any(feature = "http-api", target_arch = "wasm32",))]
+    pub type TestClient = xmtp_api_http::XmtpHttpApiClient;
+
     // Execute once before any tests are run
     #[cfg_attr(not(target_arch = "wasm32"), ctor::ctor)]
     #[cfg(not(target_arch = "wasm32"))]
diff --git a/xmtp_api/src/mls.rs b/xmtp_api/src/mls.rs
index 2d2a532866..8dcd0157b4 100644
--- a/xmtp_api/src/mls.rs
+++ b/xmtp_api/src/mls.rs
@@ -358,15 +358,12 @@ pub mod tests {
     use crate::*;
 
     use crate::test_utils::MockError;
-    use xmtp_common::StreamHandle;
+    use xmtp_proto::api_client::ApiBuilder;
     use xmtp_proto::xmtp::mls::api::v1::{
         fetch_key_packages_response::KeyPackage, FetchKeyPackagesResponse, PagingInfo,
         QueryGroupMessagesResponse,
     };
 
-    use std::sync::{Arc, Mutex};
-    use tokio::sync::Barrier;
-
     #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
     #[cfg_attr(not(target_arch = "wasm32"), tokio::test(flavor = "multi_thread"))]
     async fn test_upload_key_package() {
@@ -572,258 +569,20 @@ pub mod tests {
         assert_eq!(result.len(), 50);
     }
 
-    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
-    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
-    async fn it_cools_down_then_succeeds() {
-        let mut mock_api = MockApiClient::new();
-        let group_id = vec![1, 2, 3];
-        let group_id_clone = group_id.clone();
-        let time_failure = Arc::new(Mutex::new(None));
-        let time_success = Arc::new(Mutex::new(None));
-
-        let f = time_failure.clone();
-        mock_api
-            .expect_query_group_messages()
-            .times(1)
-            .returning(move |_| {
-                let mut set = f.lock().unwrap();
-                *set = Some(xmtp_common::time::Instant::now());
-                Err(MockError::RateLimit)
-            });
-
-        let s = time_success.clone();
-        mock_api
-            .expect_query_group_messages()
-            .times(1)
-            .returning(move |_| {
-                let mut set = s.lock().unwrap();
-                *set = Some(xmtp_common::time::Instant::now());
-                Ok(QueryGroupMessagesResponse {
-                    paging_info: None,
-                    messages: build_group_messages(50, group_id.clone()),
-                })
-            });
-        let strategy = xmtp_common::ExponentialBackoff::builder()
-            .duration(std::time::Duration::from_millis(10))
-            .build();
-        let cooldown = xmtp_common::ExponentialBackoff::builder()
-            .duration(std::time::Duration::from_secs(1))
-            .build();
-        let wrapper = ApiClientWrapper::new(
-            mock_api.into(),
-            Retry::builder()
-                .with_strategy(strategy)
-                .with_cooldown(cooldown)
-                .build(),
-        );
-
-        let result = wrapper
-            .query_group_messages(group_id_clone, None)
-            .await
-            .unwrap();
-        let failed = time_failure.lock().unwrap().unwrap();
-        let success = time_success.lock().unwrap().unwrap();
-        assert!((failed.elapsed() - success.elapsed()) > std::time::Duration::from_secs(1));
-        assert_eq!(result.len(), 50);
-    }
-
-    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
-    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
-    async fn it_exponentially_increases_the_cooldown() {
-        let mut mock_api = MockApiClient::new();
-        let group_id = vec![1, 2, 3];
-        let group_id_clone = group_id.clone();
-        let time_failure = Arc::new(Mutex::new(None));
-        let time_failure2 = Arc::new(Mutex::new(None));
-        let time_failure3 = Arc::new(Mutex::new(None));
-        let time_success = Arc::new(Mutex::new(None));
-
-        let f = time_failure.clone();
-        mock_api
-            .expect_query_group_messages()
-            .times(1)
-            .returning(move |_| {
-                let mut set = f.lock().unwrap();
-                *set = Some(xmtp_common::time::Instant::now());
-                Err(MockError::RateLimit)
-            });
-
-        let f = time_failure2.clone();
-        mock_api
-            .expect_query_group_messages()
-            .times(1)
-            .returning(move |_| {
-                let mut set = f.lock().unwrap();
-                *set = Some(xmtp_common::time::Instant::now());
-                Err(MockError::RateLimit)
-            });
-
-        let f = time_failure3.clone();
-        mock_api
-            .expect_query_group_messages()
-            .times(1)
-            .returning(move |_| {
-                let mut set = f.lock().unwrap();
-                *set = Some(xmtp_common::time::Instant::now());
-                Err(MockError::RateLimit)
-            });
-
-        let s = time_success.clone();
-        mock_api
-            .expect_query_group_messages()
-            .times(1)
-            .returning(move |_| {
-                let mut set = s.lock().unwrap();
-                *set = Some(xmtp_common::time::Instant::now());
-                Ok(QueryGroupMessagesResponse {
-                    paging_info: None,
-                    messages: build_group_messages(50, group_id.clone()),
-                })
-            });
-
-        let strategy = xmtp_common::ExponentialBackoff::builder()
-            .duration(std::time::Duration::from_millis(10))
-            .build();
-        let cooldown = xmtp_common::ExponentialBackoff::builder()
-            .duration(std::time::Duration::from_secs(1))
-            .multiplier(2)
-            .build();
-        let wrapper = ApiClientWrapper::new(
-            mock_api.into(),
-            Retry::builder()
-                .with_strategy(strategy)
-                .with_cooldown(cooldown)
-                .build(),
-        );
-
-        let result = wrapper
-            .query_group_messages(group_id_clone, None)
-            .await
-            .unwrap();
-        let failed = time_failure.lock().unwrap().unwrap();
-        let failed2 = time_failure2.lock().unwrap().unwrap();
-        let failed3 = time_failure3.lock().unwrap().unwrap();
-        let success = time_success.lock().unwrap().unwrap();
-        assert!((failed.elapsed() - success.elapsed()) > std::time::Duration::from_secs(1));
-        assert!((failed2.elapsed() - success.elapsed()) > std::time::Duration::from_secs(2));
-        tracing::info!("failed3.elapsed={:?}", failed3.elapsed());
-        assert!((failed3.elapsed() - success.elapsed()) > std::time::Duration::from_secs(4));
-
-        assert_eq!(result.len(), 50);
-    }
-
-    // this test fails on wasm, but seems to be functioning as expected
-    // It fails because of mockall expects which should be investigated
-    // but occurs in an unrelated library (mockall).
-    #[cfg_attr(target_arch = "wasm32", ignore)]
-    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
-    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
-    async fn cooldowns_apply_to_concurrent_fns() {
-        let mut mock_api = MockApiClient::new();
-        let group_id = vec![1, 2, 3];
-
-        let time_failure = Arc::new(Mutex::new(None));
-        let time_success = Arc::new(Mutex::new(Vec::new()));
-        // first task gets rate limited
-        let f = time_failure.clone();
-        mock_api
-            .expect_query_group_messages()
-            .times(1)
-            .returning(move |_| {
-                tracing::info!("called query_message rate limit");
-                let mut set = f.lock().unwrap();
-                *set = Some(xmtp_common::time::Instant::now());
-                Err(MockError::RateLimit)
-            });
-
-        let s = time_success.clone();
-        mock_api.expect_query_group_messages().times(3).returning({
-            let group_id = group_id.clone();
-            move |_| {
-                tracing::info!("Called non rate limit");
-                let set = s.lock();
-                set.unwrap().push(xmtp_common::time::Instant::now());
-                Ok(QueryGroupMessagesResponse {
-                    paging_info: None,
-                    messages: build_group_messages(50, group_id.clone()),
-                })
-            }
-        });
-
-        let strategy = xmtp_common::ExponentialBackoff::builder()
-            .duration(std::time::Duration::from_millis(10))
-            .build();
-        let cooldown = xmtp_common::ExponentialBackoff::builder()
-            .duration(std::time::Duration::from_secs(2))
-            .multiplier(2)
-            .build();
-
-        let wrapper = ApiClientWrapper::new(
-            mock_api.into(),
-            Retry::builder()
-                .with_strategy(strategy)
-                .with_cooldown(cooldown)
-                .build(),
-        );
-
-        let barrier = Arc::new(Barrier::new(4));
-        let c = barrier.clone();
-        let api = wrapper.clone();
-        let g = group_id.clone();
-        let rate_limits = xmtp_common::spawn(None, async move {
-            c.wait().await;
-            tracing::info!("Waited query_msg RATE LIMIT");
-            let _ = api.query_group_messages(g.clone(), None).await.unwrap();
-        })
-        .join();
-
-        let c = barrier.clone();
-        let g = group_id.clone();
-        let api = wrapper.clone();
-        let h1 = xmtp_common::spawn(None, async move {
-            c.wait().await;
-            tracing::info!("Waited query_msg 2 sleeping 5ms");
-            // ensure 5ms to fire after rate limit
-            xmtp_common::time::sleep(std::time::Duration::from_millis(5)).await;
-            let _ = api.query_group_messages(g, None).await;
-        })
-        .join();
-
-        let c = barrier.clone();
-        let g = group_id.clone();
-        let api = wrapper.clone();
-        let h2 = xmtp_common::spawn(None, async move {
-            c.wait().await;
-            tracing::info!("Waited query_msg 3 sleeping 5ms");
-            // ensure 5ms to fire after rate limit
-            xmtp_common::time::sleep(std::time::Duration::from_millis(5)).await;
-            let _ = api.query_group_messages(g, None).await;
-        })
-        .join();
-
-        let c = barrier.clone();
-        let g = group_id.clone();
-        let api = wrapper.clone();
-        let h3 = xmtp_common::spawn(None, async move {
-            c.wait().await;
-            tracing::info!("Waited query_msg 3 sleeping 1000ms");
-            // Firing this 1s later, should still start within duration of backoff
-            xmtp_common::time::sleep(std::time::Duration::from_millis(1_000)).await;
-            let _ = api.query_group_messages(g, None).await;
-            tracing::info!("long query finished");
-        })
-        .join();
-
-        futures::future::join_all(vec![rate_limits, h1, h2, h3]).await;
-        let fail = time_failure.lock().unwrap().unwrap();
-        let success = time_success.lock().unwrap();
-        let min_expected_cooldown = std::time::Duration::from_secs(2);
-        let max_expected_cooldown = std::time::Duration::from_millis(2050);
-
-        for s in success.iter() {
-            tracing::info!("Duration since rate limit={:?}", s.duration_since(fail));
-            assert!(s.duration_since(fail) > min_expected_cooldown);
-            assert!(s.duration_since(fail) < max_expected_cooldown);
-        }
+    // test is ignored not b/c it doesn't work, but because it takes a minimum of a minute
+    #[xmtp_common::test]
+    #[ignore]
+    async fn it_should_rate_limit() {
+        let mut client = crate::tests::TestClient::builder();
+        client.set_host("http://localhost:5556".into());
+        client.set_tls(false);
+        client.rate_per_minute(1);
+        let _ = client.set_app_version("999.999.999".into());
+        let c = client.build().await.unwrap();
+        let wrapper = ApiClientWrapper::new(c.into(), Retry::default());
+        let _first = wrapper.query_group_messages(vec![0, 0], None).await;
+        let now = std::time::Instant::now();
+        let _second = wrapper.query_group_messages(vec![0, 0], None).await;
+        assert!(now.elapsed() > std::time::Duration::from_secs(60));
     }
 }
diff --git a/xmtp_api/src/test_utils.rs b/xmtp_api/src/test_utils.rs
index 2bd0f546ad..3f0384ac48 100644
--- a/xmtp_api/src/test_utils.rs
+++ b/xmtp_api/src/test_utils.rs
@@ -29,9 +29,9 @@ use xmtp_proto::xmtp::mls::api::v1::WelcomeMessage;
 use xmtp_common::{ExponentialBackoff, Retry, RetryBuilder};
 use xmtp_proto::api_client::XmtpTestClient;
 
-pub fn exponential() -> RetryBuilder<ExponentialBackoff, ExponentialBackoff> {
+pub fn exponential() -> RetryBuilder<ExponentialBackoff> {
     let e = ExponentialBackoff::default();
-    Retry::builder().with_strategy(e.clone()).with_cooldown(e)
+    Retry::builder().with_strategy(e)
 }
 
 pub fn build_group_messages(num_messages: usize, group_id: Vec<u8>) -> Vec<GroupMessage> {
@@ -75,10 +75,6 @@ impl xmtp_common::RetryableError for MockError {
     fn is_retryable(&self) -> bool {
         true
     }
-
-    fn needs_cooldown(&self) -> bool {
-        matches!(self, MockError::RateLimit)
-    }
 }
 
 #[cfg(not(target_arch = "wasm32"))]
@@ -104,6 +100,8 @@ impl ApiBuilder for MockApiBuilder {
     async fn build(self) -> Result<Self::Output, Self::Error> {
         Ok(ApiClient)
     }
+
+    fn rate_per_minute(&mut self, _limit: u32) {}
 }
 
 // Create a mock XmtpClient for testing the client wrapper
diff --git a/xmtp_api_d14n/src/compat/d14n.rs b/xmtp_api_d14n/src/compat/d14n.rs
index 91b024679a..97f5622806 100644
--- a/xmtp_api_d14n/src/compat/d14n.rs
+++ b/xmtp_api_d14n/src/compat/d14n.rs
@@ -72,4 +72,9 @@ where
             <Builder2 as ApiBuilder>::build(self.payer_client).await?,
         ))
     }
+
+    fn rate_per_minute(&mut self, limit: u32) {
+        <Builder1 as ApiBuilder>::rate_per_minute(&mut self.message_client, limit);
+        <Builder2 as ApiBuilder>::rate_per_minute(&mut self.payer_client, limit)
+    }
 }
diff --git a/xmtp_api_d14n/src/compat/v3.rs b/xmtp_api_d14n/src/compat/v3.rs
index 88bdfbee27..66608698af 100644
--- a/xmtp_api_d14n/src/compat/v3.rs
+++ b/xmtp_api_d14n/src/compat/v3.rs
@@ -57,6 +57,10 @@ where
         <Builder as ApiBuilder>::set_tls(&mut self.client, tls)
     }
 
+    fn rate_per_minute(&mut self, limit: u32) {
+        <Builder as ApiBuilder>::rate_per_minute(&mut self.client, limit)
+    }
+
     async fn build(self) -> Result<Self::Output, Self::Error> {
         Ok(V3Client::new(
             <Builder as ApiBuilder>::build(self.client).await?,
diff --git a/xmtp_api_grpc/src/error.rs b/xmtp_api_grpc/src/error.rs
new file mode 100644
index 0000000000..803898a6cd
--- /dev/null
+++ b/xmtp_api_grpc/src/error.rs
@@ -0,0 +1,76 @@
+use thiserror::Error;
+use xmtp_proto::ConversionError;
+
+#[derive(Debug, Error)]
+pub enum GrpcBuilderError {
+    #[error("app version required to create client")]
+    MissingAppVersion,
+    #[error("libxmtp core library version required to create client")]
+    MissingLibxmtpVersion,
+    #[error("host url required to create client")]
+    MissingHostUrl,
+    #[error("payer url required to create client")]
+    MissingPayerUrl,
+    #[error(transparent)]
+    Metadata(#[from] tonic::metadata::errors::InvalidMetadataValue),
+    #[error(transparent)]
+    Transport(#[from] tonic::transport::Error),
+    #[error("Invalid URI during channel creation")]
+    InvalidUri(#[from] http::uri::InvalidUri),
+}
+
+#[derive(Debug, Error)]
+pub enum GrpcError {
+    #[error("Invalid URI during channel creation")]
+    InvalidUri(#[from] http::uri::InvalidUri),
+    #[error(transparent)]
+    Transport(#[from] tonic::transport::Error),
+    #[error(transparent)]
+    Metadata(#[from] tonic::metadata::errors::InvalidMetadataValue),
+    #[error(transparent)]
+    Status(#[from] tonic::Status),
+    #[error("{0} not found/empty")]
+    NotFound(String),
+    #[error("Payload not expected")]
+    UnexpectedPayload,
+    #[error("payload is missing")]
+    MissingPayload,
+    #[error(transparent)]
+    Proto(#[from] xmtp_proto::ProtoError),
+    #[error(transparent)]
+    Decode(#[from] prost::DecodeError),
+    #[error("unreachable (Infallible)")]
+    Unreachable,
+}
+
+impl From<ConversionError> for GrpcError {
+    fn from(error: ConversionError) -> Self {
+        GrpcError::NotFound(error.to_string())
+    }
+}
+
+impl xmtp_common::retry::RetryableError for GrpcError {
+    fn is_retryable(&self) -> bool {
+        true
+    }
+}
+
+impl xmtp_proto::XmtpApiError for GrpcError {
+    fn api_call(&self) -> Option<xmtp_proto::ApiEndpoint> {
+        None
+    }
+
+    fn code(&self) -> Option<xmtp_proto::Code> {
+        match &self {
+            GrpcError::Status(status) => Some(status.code().into()),
+            _ => None,
+        }
+    }
+
+    fn grpc_message(&self) -> Option<&str> {
+        match &self {
+            GrpcError::Status(status) => Some(status.message()),
+            _ => None,
+        }
+    }
+}
diff --git a/xmtp_api_grpc/src/grpc_api_helper.rs b/xmtp_api_grpc/src/grpc_api_helper.rs
index fe3a8985a3..fb72c71019 100644
--- a/xmtp_api_grpc/src/grpc_api_helper.rs
+++ b/xmtp_api_grpc/src/grpc_api_helper.rs
@@ -2,12 +2,10 @@ use std::pin::Pin;
 use std::time::Duration;
 
 use futures::{Stream, StreamExt};
-use tonic::transport::ClientTlsConfig;
 use tonic::{metadata::MetadataValue, transport::Channel, Request};
-use tracing::Instrument;
 use xmtp_proto::traits::ApiClientError;
 
-use crate::{GrpcBuilderError, GrpcError};
+use crate::{create_tls_channel, GrpcBuilderError, GrpcError};
 use xmtp_proto::api_client::{ApiBuilder, ApiStats, IdentityStats, XmtpMlsStreams};
 use xmtp_proto::xmtp::mls::api::v1::{GroupMessage, WelcomeMessage};
 use xmtp_proto::{
@@ -23,42 +21,6 @@ use xmtp_proto::{
     ApiEndpoint,
 };
 
-#[tracing::instrument(level = "trace", skip_all)]
-pub async fn create_tls_channel(address: String) -> Result<Channel, GrpcBuilderError> {
-    let span = tracing::debug_span!("grpc_connect", address);
-    let channel = Channel::from_shared(address)?
-        // Purpose: This setting controls the size of the initial connection-level flow control window for HTTP/2, which is the underlying protocol for gRPC.
-        // Functionality: Flow control in HTTP/2 manages how much data can be in flight on the network. Setting the initial connection window size to (1 << 31) - 1 (the maximum possible value for a 32-bit integer, which is 2,147,483,647 bytes) essentially allows the client to receive a very large amount of data from the server before needing to acknowledge receipt and permit more data to be sent. This can be particularly useful in high-latency networks or when transferring large amounts of data.
-        // Impact: Increasing the window size can improve throughput by allowing more data to be in transit at a time, but it may also increase memory usage and can potentially lead to inefficient use of bandwidth if the network is unreliable.
-        .initial_connection_window_size(Some((1 << 31) - 1))
-        // Purpose: Configures whether the client should send keep-alive pings to the server when the connection is idle.
-        // Functionality: When set to true, this option ensures that periodic pings are sent on an idle connection to keep it alive and detect if the server is still responsive.
-        // Impact: This helps maintain active connections, particularly through NATs, load balancers, and other middleboxes that might drop idle connections. It helps ensure that the connection is promptly usable when new requests need to be sent.
-        .keep_alive_while_idle(true)
-        // Purpose: Sets the maximum amount of time the client will wait for a connection to be established.
-        // Functionality: If a connection cannot be established within the specified duration, the attempt is aborted and an error is returned.
-        // Impact: This setting prevents the client from waiting indefinitely for a connection to be established, which is crucial in scenarios where rapid failure detection is necessary to maintain responsiveness or to quickly fallback to alternative services or retry logic.
-        .connect_timeout(Duration::from_secs(10))
-        // Purpose: Configures the TCP keep-alive interval for the socket connection.
-        // Functionality: This setting tells the operating system to send TCP keep-alive probes periodically when no data has been transferred over the connection within the specified interval.
-        // Impact: Similar to the gRPC-level keep-alive, this helps keep the connection alive at the TCP layer and detect broken connections. It's particularly useful for detecting half-open connections and ensuring that resources are not wasted on unresponsive peers.
-        .tcp_keepalive(Some(Duration::from_secs(15)))
-        // Purpose: Sets a maximum duration for the client to wait for a response to a request.
-        // Functionality: If a response is not received within the specified timeout, the request is canceled and an error is returned.
-        // Impact: This is critical for bounding the wait time for operations, which can enhance the predictability and reliability of client interactions by avoiding indefinitely hanging requests.
-        .timeout(Duration::from_secs(120))
-        // Purpose: Specifies how long the client will wait for a response to a keep-alive ping before considering the connection dead.
-        // Functionality: If a ping response is not received within this duration, the connection is presumed to be lost and is closed.
-        // Impact: This setting is crucial for quickly detecting unresponsive connections and freeing up resources associated with them. It ensures that the client has up-to-date information on the status of connections and can react accordingly.
-        .keep_alive_timeout(Duration::from_secs(25))
-        .tls_config(ClientTlsConfig::new().with_enabled_roots())?
-        .connect()
-        .instrument(span)
-        .await?;
-
-    Ok(channel)
-}
-
 #[derive(Debug, Clone)]
 pub struct Client {
     pub(crate) mls_client: ProtoMlsApiClient<Channel>,
@@ -70,28 +32,11 @@ pub struct Client {
 }
 
 impl Client {
-    #[tracing::instrument(level = "trace", skip_all)]
     pub async fn create(host: impl ToString, is_secure: bool) -> Result<Self, GrpcBuilderError> {
-        let host = host.to_string();
-        let app_version = MetadataValue::try_from(&String::from("0.0.0"))?;
-        let libxmtp_version = MetadataValue::try_from(env!("CARGO_PKG_VERSION").to_string())?;
-
-        let channel = match is_secure {
-            true => create_tls_channel(host).await?,
-            false => Channel::from_shared(host)?.connect().await?,
-        };
-
-        let mls_client = ProtoMlsApiClient::new(channel.clone());
-        let identity_client = ProtoIdentityApiClient::new(channel);
-
-        Ok(Self {
-            mls_client,
-            app_version,
-            libxmtp_version,
-            identity_client,
-            stats: ApiStats::default(),
-            identity_stats: IdentityStats::default(),
-        })
+        let mut b = Self::builder();
+        b.set_tls(is_secure);
+        b.set_host(host.to_string());
+        b.build().await
     }
 
     pub fn build_request<RequestType>(&self, request: RequestType) -> Request<RequestType> {
@@ -122,6 +67,8 @@ pub struct ClientBuilder {
     libxmtp_version: Option<MetadataValue<tonic::metadata::Ascii>>,
     /// Whether or not the channel should use TLS
     tls_channel: bool,
+    /// Rate per minute
+    limit: Option<u64>,
 }
 
 impl ApiBuilder for ClientBuilder {
@@ -146,11 +93,20 @@ impl ApiBuilder for ClientBuilder {
         self.host = Some(host);
     }
 
+    fn rate_per_minute(&mut self, limit: u32) {
+        self.limit = Some(limit.into());
+    }
+
     async fn build(self) -> Result<Self::Output, Self::Error> {
         let host = self.host.ok_or(GrpcBuilderError::MissingHostUrl)?;
         let channel = match self.tls_channel {
-            true => create_tls_channel(host).await?,
-            false => Channel::from_shared(host)?.connect().await?,
+            true => create_tls_channel(host, self.limit.unwrap_or(1900)).await?,
+            false => {
+                Channel::from_shared(host)?
+                    .rate_limit(self.limit.unwrap_or(1900), Duration::from_secs(60))
+                    .connect()
+                    .await?
+            }
         };
 
         let mls_client = ProtoMlsApiClient::new(channel.clone());
diff --git a/xmtp_api_grpc/src/grpc_client.rs b/xmtp_api_grpc/src/grpc_client.rs
index a11578666d..0a77eb7bfe 100644
--- a/xmtp_api_grpc/src/grpc_client.rs
+++ b/xmtp_api_grpc/src/grpc_client.rs
@@ -1,18 +1,16 @@
 use prost::bytes::Bytes;
 use std::time::Duration;
-use thiserror::Error;
 use tonic::{
     metadata::{self, MetadataMap, MetadataValue},
-    transport::{self, Channel, ClientTlsConfig},
+    transport::Channel,
 };
-use tracing::Instrument;
 use xmtp_proto::{
     api_client::ApiBuilder,
     codec::TransparentCodec,
     traits::{ApiClientError, Client},
 };
 
-use crate::GrpcError;
+use crate::{create_tls_channel, GrpcBuilderError, GrpcError};
 
 impl From<GrpcError> for ApiClientError<GrpcError> {
     fn from(source: GrpcError) -> ApiClientError<GrpcError> {
@@ -102,24 +100,8 @@ pub struct ClientBuilder {
     libxmtp_version: Option<MetadataValue<metadata::Ascii>>,
     /// Whether or not the channel should use TLS
     tls_channel: bool,
-}
-
-#[derive(Debug, Error)]
-pub enum GrpcBuilderError {
-    #[error("app version required to create client")]
-    MissingAppVersion,
-    #[error("libxmtp core library version required to create client")]
-    MissingLibxmtpVersion,
-    #[error("host url required to create client")]
-    MissingHostUrl,
-    #[error("payer url required to create client")]
-    MissingPayerUrl,
-    #[error(transparent)]
-    Metadata(#[from] metadata::errors::InvalidMetadataValue),
-    #[error(transparent)]
-    Transport(#[from] transport::Error),
-    #[error("Invalid URI during channel creation")]
-    InvalidUri(#[from] http::uri::InvalidUri),
+    /// Rate Limit
+    limit: Option<u64>,
 }
 
 impl ApiBuilder for ClientBuilder {
@@ -144,11 +126,20 @@ impl ApiBuilder for ClientBuilder {
         self.host = Some(host);
     }
 
+    fn rate_per_minute(&mut self, limit: u32) {
+        self.limit = Some(limit.into());
+    }
+
     async fn build(self) -> Result<Self::Output, Self::Error> {
         let host = self.host.ok_or(GrpcBuilderError::MissingHostUrl)?;
         let channel = match self.tls_channel {
-            true => create_tls_channel(host).await?,
-            false => Channel::from_shared(host)?.connect().await?,
+            true => create_tls_channel(host, self.limit.unwrap_or(1900)).await?,
+            false => {
+                Channel::from_shared(host)?
+                    .rate_limit(self.limit.unwrap_or(1900), Duration::from_secs(60))
+                    .connect()
+                    .await?
+            }
         };
 
         Ok(GrpcClient {
@@ -163,42 +154,6 @@ impl ApiBuilder for ClientBuilder {
     }
 }
 
-#[tracing::instrument(level = "trace", skip_all)]
-pub async fn create_tls_channel(address: String) -> Result<Channel, GrpcBuilderError> {
-    let span = tracing::debug_span!("grpc_connect", address);
-    let channel = Channel::from_shared(address)?
-        // Purpose: This setting controls the size of the initial connection-level flow control window for HTTP/2, which is the underlying protocol for gRPC.
-        // Functionality: Flow control in HTTP/2 manages how much data can be in flight on the network. Setting the initial connection window size to (1 << 31) - 1 (the maximum possible value for a 32-bit integer, which is 2,147,483,647 bytes) essentially allows the client to receive a very large amount of data from the server before needing to acknowledge receipt and permit more data to be sent. This can be particularly useful in high-latency networks or when transferring large amounts of data.
-        // Impact: Increasing the window size can improve throughput by allowing more data to be in transit at a time, but it may also increase memory usage and can potentially lead to inefficient use of bandwidth if the network is unreliable.
-        .initial_connection_window_size(Some((1 << 31) - 1))
-        // Purpose: Configures whether the client should send keep-alive pings to the server when the connection is idle.
-        // Functionality: When set to true, this option ensures that periodic pings are sent on an idle connection to keep it alive and detect if the server is still responsive.
-        // Impact: This helps maintain active connections, particularly through NATs, load balancers, and other middleboxes that might drop idle connections. It helps ensure that the connection is promptly usable when new requests need to be sent.
-        .keep_alive_while_idle(true)
-        // Purpose: Sets the maximum amount of time the client will wait for a connection to be established.
-        // Functionality: If a connection cannot be established within the specified duration, the attempt is aborted and an error is returned.
-        // Impact: This setting prevents the client from waiting indefinitely for a connection to be established, which is crucial in scenarios where rapid failure detection is necessary to maintain responsiveness or to quickly fallback to alternative services or retry logic.
-        .connect_timeout(Duration::from_secs(10))
-        // Purpose: Configures the TCP keep-alive interval for the socket connection.
-        // Functionality: This setting tells the operating system to send TCP keep-alive probes periodically when no data has been transferred over the connection within the specified interval.
-        // Impact: Similar to the gRPC-level keep-alive, this helps keep the connection alive at the TCP layer and detect broken connections. It's particularly useful for detecting half-open connections and ensuring that resources are not wasted on unresponsive peers.
-        .tcp_keepalive(Some(Duration::from_secs(15)))
-        // Purpose: Sets a maximum duration for the client to wait for a response to a request.
-        // Functionality: If a response is not received within the specified timeout, the request is canceled and an error is returned.
-        // Impact: This is critical for bounding the wait time for operations, which can enhance the predictability and reliability of client interactions by avoiding indefinitely hanging requests.
-        .timeout(Duration::from_secs(120))
-        // Purpose: Specifies how long the client will wait for a response to a keep-alive ping before considering the connection dead.
-        // Functionality: If a ping response is not received within this duration, the connection is presumed to be lost and is closed.
-        // Impact: This setting is crucial for quickly detecting unresponsive connections and freeing up resources associated with them. It ensures that the client has up-to-date information on the status of connections and can react accordingly.
-        .keep_alive_timeout(Duration::from_secs(25))
-        .tls_config(ClientTlsConfig::new().with_enabled_roots())?
-        .connect()
-        .instrument(span)
-        .await?;
-
-    Ok(channel)
-}
-
 #[cfg(any(test, feature = "test-utils"))]
 mod test {
     use super::*;
diff --git a/xmtp_api_grpc/src/lib.rs b/xmtp_api_grpc/src/lib.rs
index 2e47fe7ca1..bf8b1ff80d 100644
--- a/xmtp_api_grpc/src/lib.rs
+++ b/xmtp_api_grpc/src/lib.rs
@@ -1,88 +1,53 @@
+pub mod error;
 pub mod grpc_api_helper;
 pub mod grpc_client;
+pub use error::*;
 mod identity;
 
 pub const LOCALHOST_ADDRESS: &str = "http://localhost:5556";
 pub const DEV_ADDRESS: &str = "https://grpc.dev.xmtp.network:443";
 
 pub use grpc_api_helper::{Client, GroupMessageStream, WelcomeMessageStream};
-use thiserror::Error;
-use xmtp_proto::ConversionError;
+use std::time::Duration;
+use tonic::transport::{Channel, ClientTlsConfig};
+use tracing::Instrument;
 
-#[derive(Debug, Error)]
-pub enum GrpcBuilderError {
-    #[error("app version required to create client")]
-    MissingAppVersion,
-    #[error("libxmtp core library version required to create client")]
-    MissingLibxmtpVersion,
-    #[error("host url required to create client")]
-    MissingHostUrl,
-    #[error("payer url required to create client")]
-    MissingPayerUrl,
-    #[error(transparent)]
-    Metadata(#[from] tonic::metadata::errors::InvalidMetadataValue),
-    #[error(transparent)]
-    Transport(#[from] tonic::transport::Error),
-    #[error("Invalid URI during channel creation")]
-    InvalidUri(#[from] http::uri::InvalidUri),
-}
+#[tracing::instrument(level = "trace", skip_all)]
+pub async fn create_tls_channel(address: String, limit: u64) -> Result<Channel, GrpcBuilderError> {
+    let span = tracing::debug_span!("grpc_connect", address);
+    let channel = Channel::from_shared(address)?
+        .rate_limit(limit, Duration::from_secs(60))
+        // Purpose: This setting controls the size of the initial connection-level flow control window for HTTP/2, which is the underlying protocol for gRPC.
+        // Functionality: Flow control in HTTP/2 manages how much data can be in flight on the network. Setting the initial connection window size to (1 << 31) - 1 (the maximum possible value for a 32-bit integer, which is 2,147,483,647 bytes) essentially allows the client to receive a very large amount of data from the server before needing to acknowledge receipt and permit more data to be sent. This can be particularly useful in high-latency networks or when transferring large amounts of data.
+        // Impact: Increasing the window size can improve throughput by allowing more data to be in transit at a time, but it may also increase memory usage and can potentially lead to inefficient use of bandwidth if the network is unreliable.
+        .initial_connection_window_size(Some((1 << 31) - 1))
+        // Purpose: Configures whether the client should send keep-alive pings to the server when the connection is idle.
+        // Functionality: When set to true, this option ensures that periodic pings are sent on an idle connection to keep it alive and detect if the server is still responsive.
+        // Impact: This helps maintain active connections, particularly through NATs, load balancers, and other middleboxes that might drop idle connections. It helps ensure that the connection is promptly usable when new requests need to be sent.
+        .keep_alive_while_idle(true)
+        // Purpose: Sets the maximum amount of time the client will wait for a connection to be established.
+        // Functionality: If a connection cannot be established within the specified duration, the attempt is aborted and an error is returned.
+        // Impact: This setting prevents the client from waiting indefinitely for a connection to be established, which is crucial in scenarios where rapid failure detection is necessary to maintain responsiveness or to quickly fallback to alternative services or retry logic.
+        .connect_timeout(Duration::from_secs(10))
+        // Purpose: Configures the TCP keep-alive interval for the socket connection.
+        // Functionality: This setting tells the operating system to send TCP keep-alive probes periodically when no data has been transferred over the connection within the specified interval.
+        // Impact: Similar to the gRPC-level keep-alive, this helps keep the connection alive at the TCP layer and detect broken connections. It's particularly useful for detecting half-open connections and ensuring that resources are not wasted on unresponsive peers.
+        .tcp_keepalive(Some(Duration::from_secs(15)))
+        // Purpose: Sets a maximum duration for the client to wait for a response to a request.
+        // Functionality: If a response is not received within the specified timeout, the request is canceled and an error is returned.
+        // Impact: This is critical for bounding the wait time for operations, which can enhance the predictability and reliability of client interactions by avoiding indefinitely hanging requests.
+        .timeout(Duration::from_secs(120))
+        // Purpose: Specifies how long the client will wait for a response to a keep-alive ping before considering the connection dead.
+        // Functionality: If a ping response is not received within this duration, the connection is presumed to be lost and is closed.
+        // Impact: This setting is crucial for quickly detecting unresponsive connections and freeing up resources associated with them. It ensures that the client has up-to-date information on the status of connections and can react accordingly.
+        .keep_alive_timeout(Duration::from_secs(25))
+        .tls_config(ClientTlsConfig::new().with_enabled_roots())?
+        .connect()
+        .instrument(span)
+        .await?;
 
-#[derive(Debug, Error)]
-pub enum GrpcError {
-    #[error("Invalid URI during channel creation")]
-    InvalidUri(#[from] http::uri::InvalidUri),
-    #[error(transparent)]
-    Transport(#[from] tonic::transport::Error),
-    #[error(transparent)]
-    Metadata(#[from] tonic::metadata::errors::InvalidMetadataValue),
-    #[error(transparent)]
-    Status(#[from] tonic::Status),
-    #[error("{0} not found/empty")]
-    NotFound(String),
-    #[error("Payload not expected")]
-    UnexpectedPayload,
-    #[error("payload is missing")]
-    MissingPayload,
-    #[error(transparent)]
-    Proto(#[from] xmtp_proto::ProtoError),
-    #[error(transparent)]
-    Decode(#[from] prost::DecodeError),
-    #[error("unreachable (Infallible)")]
-    Unreachable,
+    Ok(channel)
 }
-
-impl From<ConversionError> for GrpcError {
-    fn from(error: ConversionError) -> Self {
-        GrpcError::NotFound(error.to_string())
-    }
-}
-
-impl xmtp_common::retry::RetryableError for GrpcError {
-    fn is_retryable(&self) -> bool {
-        true
-    }
-}
-
-impl xmtp_proto::XmtpApiError for GrpcError {
-    fn api_call(&self) -> Option<xmtp_proto::ApiEndpoint> {
-        None
-    }
-
-    fn code(&self) -> Option<xmtp_proto::Code> {
-        match &self {
-            GrpcError::Status(status) => Some(status.code().into()),
-            _ => None,
-        }
-    }
-
-    fn grpc_message(&self) -> Option<&str> {
-        match &self {
-            GrpcError::Status(status) => Some(status.message()),
-            _ => None,
-        }
-    }
-}
-
 #[cfg(test)]
 pub mod tests {
     use std::time::{SystemTime, UNIX_EPOCH};
diff --git a/xmtp_api_http/Cargo.toml b/xmtp_api_http/Cargo.toml
index c63151bc19..0eb2e261c9 100644
--- a/xmtp_api_http/Cargo.toml
+++ b/xmtp_api_http/Cargo.toml
@@ -8,6 +8,11 @@ version.workspace = true
 async-trait.workspace = true
 bytes = "1.9"
 futures = { workspace = true, default-features = false }
+governor = { version = "0.10", default-features = false, features = [
+  "std",
+  "quanta",
+  "jitter",
+] }
 http = "1.2"
 pin-project-lite = "0.2.15"
 prost.workspace = true
diff --git a/xmtp_api_http/src/error.rs b/xmtp_api_http/src/error.rs
index e1c89c6f7c..340a6cb378 100644
--- a/xmtp_api_http/src/error.rs
+++ b/xmtp_api_http/src/error.rs
@@ -40,13 +40,6 @@ impl xmtp_common::RetryableError for HttpClientError {
     fn is_retryable(&self) -> bool {
         true
     }
-
-    fn needs_cooldown(&self) -> bool {
-        match self {
-            Self::Grpc(e) => (Code::from(e.code)) == Code::ResourceExhausted,
-            _ => false,
-        }
-    }
 }
 
 impl XmtpApiError for HttpClientError {
diff --git a/xmtp_api_http/src/http_client.rs b/xmtp_api_http/src/http_client.rs
index 517ac76bd9..6f1ab6fa79 100644
--- a/xmtp_api_http/src/http_client.rs
+++ b/xmtp_api_http/src/http_client.rs
@@ -16,6 +16,7 @@ impl XmtpHttpApiClient {
         path: http::uri::PathAndQuery,
         body: Bytes,
     ) -> Result<http::Response<Bytes>, HttpClientError> {
+        self.wait_for_ready().await;
         let host = http::uri::Builder::from(http::uri::Uri::try_from(self.host_url.clone())?);
         let uri = host.path_and_query(path).build()?;
         trace!("uri={uri}");
diff --git a/xmtp_api_http/src/lib.rs b/xmtp_api_http/src/lib.rs
index d75985411d..31ad429c26 100755
--- a/xmtp_api_http/src/lib.rs
+++ b/xmtp_api_http/src/lib.rs
@@ -14,6 +14,13 @@ use reqwest::header;
 use reqwest::header::HeaderMap;
 use util::handle_error_proto;
 
+use governor::clock::DefaultClock;
+use governor::middleware::NoOpMiddleware;
+use governor::state::{InMemoryState, NotKeyed};
+use governor::{Jitter, Quota};
+use std::num::NonZeroU32;
+use std::sync::Arc;
+use std::time::Duration;
 use xmtp_proto::api_client::{ApiBuilder, ApiStats, IdentityStats, XmtpIdentityClient};
 use xmtp_proto::traits::ApiClientError;
 use xmtp_proto::xmtp::identity::api::v1::{
@@ -41,6 +48,8 @@ use crate::constants::ApiEndpoints;
 pub use crate::error::{ErrorResponse, HttpClientError};
 pub const LOCALHOST_ADDRESS: &str = "http://localhost:5555";
 
+type Limiter = governor::RateLimiter<NotKeyed, InMemoryState, DefaultClock, NoOpMiddleware>;
+
 #[cfg(target_arch = "wasm32")]
 fn reqwest_builder() -> reqwest::ClientBuilder {
     reqwest::Client::builder()
@@ -57,26 +66,26 @@ pub struct XmtpHttpApiClient {
     host_url: String,
     app_version: String,
     libxmtp_version: String,
+    limiter: Arc<Limiter>,
     stats: ApiStats,
     identity_stats: IdentityStats,
 }
 
 impl XmtpHttpApiClient {
-    pub fn new(host_url: String, app_version: String) -> Result<Self, HttpClientError> {
-        let client = reqwest_builder();
-        let libxmtp_version = env!("CARGO_PKG_VERSION").to_string();
-        let mut headers = header::HeaderMap::new();
-        headers.insert("x-libxmtp-version", libxmtp_version.parse()?);
-        let client = client.default_headers(headers).build()?;
+    pub async fn new(
+        host_url: String,
+        app_version: String,
+    ) -> Result<Self, HttpClientBuilderError> {
+        let mut b = Self::builder();
+        b.set_host(host_url);
+        b.set_app_version(app_version)?;
+        b.build().await
+    }
 
-        Ok(XmtpHttpApiClient {
-            http_client: client,
-            host_url,
-            app_version,
-            libxmtp_version,
-            stats: ApiStats::default(),
-            identity_stats: IdentityStats::default(),
-        })
+    /// Wait for any rate limit
+    async fn wait_for_ready(&self) {
+        let jitter = Jitter::up_to(Duration::from_secs(5));
+        self.limiter.until_ready_with_jitter(jitter).await;
     }
 
     pub fn builder() -> XmtpHttpApiClientBuilder {
@@ -96,13 +105,31 @@ impl XmtpHttpApiClient {
     }
 }
 
+#[derive(thiserror::Error, Debug)]
+pub enum HttpClientBuilderError {
+    #[error("missing core libxmtp version")]
+    MissingLibxmtpVersion,
+    #[error("missing app version")]
+    MissingAppVersion,
+    #[error(transparent)]
+    ReqwestErrror(#[from] reqwest::Error),
+    #[error(transparent)]
+    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
+    #[error(transparent)]
+    InvalidUri(#[from] http::uri::InvalidUri),
+    #[error(transparent)]
+    InvalidUriParts(#[from] http::uri::InvalidUriParts),
+}
+
 #[derive(Debug)]
 pub struct XmtpHttpApiClientBuilder {
     host_url: String,
     app_version: Option<String>,
     headers: header::HeaderMap,
     libxmtp_version: Option<String>,
+    tls: bool,
     reqwest: reqwest::ClientBuilder,
+    limiter: Option<Limiter>,
 }
 
 impl Default for XmtpHttpApiClientBuilder {
@@ -112,27 +139,13 @@ impl Default for XmtpHttpApiClientBuilder {
             app_version: None,
             libxmtp_version: None,
             headers: header::HeaderMap::new(),
+            tls: true,
+            limiter: None,
             reqwest: reqwest_builder(),
         }
     }
 }
 
-#[derive(thiserror::Error, Debug)]
-pub enum HttpClientBuilderError {
-    #[error("missing core libxmtp version")]
-    MissingLibxmtpVersion,
-    #[error("missing app version")]
-    MissingAppVersion,
-    #[error(transparent)]
-    ReqwestErrror(#[from] reqwest::Error),
-    #[error(transparent)]
-    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
-    #[error(transparent)]
-    InvalidUri(#[from] http::uri::InvalidUri),
-    #[error(transparent)]
-    InvalidUriParts(#[from] http::uri::InvalidUriParts),
-}
-
 impl ApiBuilder for XmtpHttpApiClientBuilder {
     type Output = XmtpHttpApiClient;
     type Error = HttpClientBuilderError;
@@ -154,8 +167,8 @@ impl ApiBuilder for XmtpHttpApiClientBuilder {
     }
 
     // no op for http so far
-    fn set_tls(&mut self, _tls: bool) {
-        self.reqwest = reqwest_builder();
+    fn set_tls(&mut self, tls: bool) {
+        self.tls = tls;
     }
 
     async fn build(self) -> Result<Self::Output, Self::Error> {
@@ -167,6 +180,12 @@ impl ApiBuilder for XmtpHttpApiClientBuilder {
             .app_version
             .ok_or(HttpClientBuilderError::MissingAppVersion)?;
 
+        let limiter = self.limiter.unwrap_or_else(|| {
+            let limit = NonZeroU32::new(1900).expect("1900 is greater than 0");
+            let quota = Quota::per_minute(limit);
+            Limiter::direct(quota)
+        });
+
         Ok(XmtpHttpApiClient {
             http_client,
             host_url: self.host_url,
@@ -174,8 +193,19 @@ impl ApiBuilder for XmtpHttpApiClientBuilder {
             libxmtp_version,
             stats: ApiStats::default(),
             identity_stats: IdentityStats::default(),
+            limiter: limiter.into(),
         })
     }
+
+    fn rate_per_minute(&mut self, limit: u32) {
+        let limit = if limit == 0 {
+            NonZeroU32::new(1_u32).expect("1 is greater than 0")
+        } else {
+            NonZeroU32::new(limit).expect("checked for 0")
+        };
+        let quota = Quota::per_minute(limit);
+        self.limiter = Some(Limiter::direct(quota));
+    }
 }
 
 fn protobuf_headers() -> Result<HeaderMap, HttpClientError> {
@@ -195,6 +225,7 @@ impl XmtpMlsClient for XmtpHttpApiClient {
         &self,
         request: UploadKeyPackageRequest,
     ) -> Result<(), Self::Error> {
+        self.wait_for_ready().await;
         self.stats.upload_key_package.count_request();
 
         let res = self
@@ -217,6 +248,7 @@ impl XmtpMlsClient for XmtpHttpApiClient {
         &self,
         request: FetchKeyPackagesRequest,
     ) -> Result<FetchKeyPackagesResponse, Self::Error> {
+        self.wait_for_ready().await;
         let res = self
             .http_client
             .post(self.endpoint(ApiEndpoints::FETCH_KEY_PACKAGES))
@@ -237,6 +269,7 @@ impl XmtpMlsClient for XmtpHttpApiClient {
         &self,
         request: SendGroupMessagesRequest,
     ) -> Result<(), Self::Error> {
+        self.wait_for_ready().await;
         self.stats.send_group_messages.count_request();
 
         let res = self
@@ -260,6 +293,7 @@ impl XmtpMlsClient for XmtpHttpApiClient {
         &self,
         request: SendWelcomeMessagesRequest,
     ) -> Result<(), Self::Error> {
+        self.wait_for_ready().await;
         self.stats.send_welcome_messages.count_request();
         let res = self
             .http_client
@@ -282,6 +316,7 @@ impl XmtpMlsClient for XmtpHttpApiClient {
         &self,
         request: QueryGroupMessagesRequest,
     ) -> Result<QueryGroupMessagesResponse, Self::Error> {
+        self.wait_for_ready().await;
         self.stats.query_group_messages.count_request();
         let res = self
             .http_client
@@ -304,6 +339,7 @@ impl XmtpMlsClient for XmtpHttpApiClient {
         &self,
         request: QueryWelcomeMessagesRequest,
     ) -> Result<QueryWelcomeMessagesResponse, Self::Error> {
+        self.wait_for_ready().await;
         self.stats.query_welcome_messages.count_request();
         let res = self
             .http_client
@@ -353,6 +389,7 @@ impl XmtpMlsStreams for XmtpHttpApiClient {
         &self,
         request: SubscribeGroupMessagesRequest,
     ) -> Result<Self::GroupMessageStream<'_>, Self::Error> {
+        self.wait_for_ready().await;
         Ok(create_grpc_stream::<_, GroupMessage>(
             request,
             self.endpoint(ApiEndpoints::SUBSCRIBE_GROUP_MESSAGES),
@@ -366,6 +403,7 @@ impl XmtpMlsStreams for XmtpHttpApiClient {
         &self,
         request: SubscribeWelcomeMessagesRequest,
     ) -> Result<Self::WelcomeMessageStream<'_>, Self::Error> {
+        self.wait_for_ready().await;
         tracing::debug!("subscribe_welcome_messages");
         Ok(create_grpc_stream::<_, WelcomeMessage>(
             request,
@@ -385,6 +423,7 @@ impl XmtpIdentityClient for XmtpHttpApiClient {
         &self,
         request: PublishIdentityUpdateRequest,
     ) -> Result<PublishIdentityUpdateResponse, Self::Error> {
+        self.wait_for_ready().await;
         let res = self
             .http_client
             .post(self.endpoint(ApiEndpoints::PUBLISH_IDENTITY_UPDATE))
@@ -406,6 +445,7 @@ impl XmtpIdentityClient for XmtpHttpApiClient {
         &self,
         request: GetIdentityUpdatesV2Request,
     ) -> Result<GetIdentityUpdatesV2Response, Self::Error> {
+        self.wait_for_ready().await;
         let res = self
             .http_client
             .post(self.endpoint(ApiEndpoints::GET_IDENTITY_UPDATES))
@@ -427,6 +467,7 @@ impl XmtpIdentityClient for XmtpHttpApiClient {
         &self,
         request: GetInboxIdsRequest,
     ) -> Result<GetInboxIdsResponse, Self::Error> {
+        self.wait_for_ready().await;
         let res = self
             .http_client
             .post(self.endpoint(ApiEndpoints::GET_INBOX_IDS))
@@ -446,6 +487,7 @@ impl XmtpIdentityClient for XmtpHttpApiClient {
         &self,
         request: VerifySmartContractWalletSignaturesRequest,
     ) -> Result<VerifySmartContractWalletSignaturesResponse, Self::Error> {
+        self.wait_for_ready().await;
         let res = self
             .http_client
             .post(self.endpoint(ApiEndpoints::VERIFY_SMART_CONTRACT_WALLET_SIGNATURES))
diff --git a/xmtp_mls/src/builder.rs b/xmtp_mls/src/builder.rs
index 7fb06f90c5..8e0a574870 100644
--- a/xmtp_mls/src/builder.rs
+++ b/xmtp_mls/src/builder.rs
@@ -159,14 +159,7 @@ impl<ApiClient, V> ClientBuilder<ApiClient, V> {
     }
 
     pub fn api_client<A>(self, api_client: A) -> ClientBuilder<A, V> {
-        let cooldown = xmtp_common::ExponentialBackoff::builder()
-            .duration(std::time::Duration::from_secs(3))
-            .multiplier(3)
-            .max_jitter(std::time::Duration::from_millis(100))
-            .total_wait_max(std::time::Duration::from_secs(120))
-            .build();
-
-        let api_retry = Retry::builder().with_cooldown(cooldown).build();
+        let api_retry = Retry::builder().build();
         let wrapper = ApiClientWrapper::new(Arc::new(api_client), api_retry);
         ClientBuilder {
             api_client: Some(wrapper),
@@ -280,9 +273,8 @@ pub(crate) mod tests {
         client.register_identity(signature_request).await.unwrap();
     }
 
-    fn retry() -> Retry<ExponentialBackoff, ExponentialBackoff> {
-        let strategy = ExponentialBackoff::default();
-        Retry::builder().with_cooldown(strategy).build()
+    fn retry() -> Retry<ExponentialBackoff> {
+        Retry::default()
     }
 
     /// Generate a random legacy key proto bytes and corresponding account address.
diff --git a/xmtp_proto/src/api_client.rs b/xmtp_proto/src/api_client.rs
index 24b7abb06c..67476a759e 100644
--- a/xmtp_proto/src/api_client.rs
+++ b/xmtp_proto/src/api_client.rs
@@ -593,6 +593,9 @@ pub trait ApiBuilder {
     /// indicate tls (default: false)
     fn set_tls(&mut self, tls: bool);
 
+    /// Set  the rate limit per minute for this client
+    fn rate_per_minute(&mut self, limit: u32);
+
     #[allow(async_fn_in_trait)]
     /// Build the api client
     async fn build(self) -> Result<Self::Output, Self::Error>;
diff --git a/xmtp_proto/src/error.rs b/xmtp_proto/src/error.rs
index 410c1917fd..43d8865a9a 100644
--- a/xmtp_proto/src/error.rs
+++ b/xmtp_proto/src/error.rs
@@ -22,14 +22,6 @@ impl RetryableError for ApiError {
     fn is_retryable(&self) -> bool {
         self.inner.is_retryable()
     }
-
-    fn needs_cooldown(&self) -> bool {
-        if let Some(code) = self.inner.code() {
-            code == Code::ResourceExhausted
-        } else {
-            false
-        }
-    }
 }
 
 impl std::fmt::Display for ApiError {
diff --git a/xmtp_proto/src/traits.rs b/xmtp_proto/src/traits.rs
index ba1e61e8ea..725e2d3bd8 100644
--- a/xmtp_proto/src/traits.rs
+++ b/xmtp_proto/src/traits.rs
@@ -261,6 +261,8 @@ pub mod mock {
         async fn build(self) -> Result<Self::Output, Self::Error> {
             Ok(MockClient)
         }
+
+        fn rate_per_minute(&mut self, _limit: u32) {}
     }
 
     #[derive(thiserror::Error, Debug)]


Reviews:
user: login: neekolas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T00:29:42Z
user: login: neekolas
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T00:30:34Z
user: login: insipx
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T00:32:14Z
user: login: neekolas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T00:35:37Z
user: login: insipx
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T00:39:27Z
user: login: macroscopeapp[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T00:53:56Z

Review comments:
diff_hunk: @@ -57,26 +66,16 @@ pub struct XmtpHttpApiClient {
     host_url: String,
     app_version: String,
     libxmtp_version: String,
+    limiter: Arc<Limiter>,
     stats: ApiStats,
     identity_stats: IdentityStats,
 }
 
 impl XmtpHttpApiClient {
-    pub fn new(host_url: String, app_version: String) -> Result<Self, HttpClientError> {
-        let client = reqwest_builder();
-        let libxmtp_version = env!("CARGO_PKG_VERSION").to_string();
-        let mut headers = header::HeaderMap::new();
-        headers.insert("x-libxmtp-version", libxmtp_version.parse()?);
-        let client = client.default_headers(headers).build()?;
-
-        Ok(XmtpHttpApiClient {
-            http_client: client,
-            host_url,
-            app_version,
-            libxmtp_version,
-            stats: ApiStats::default(),
-            identity_stats: IdentityStats::default(),
-        })
+    /// Wait for any rate limit
+    async fn wait_for_ready(&self) {
+        let jitter = Jitter::up_to(Duration::from_secs(5));
path: xmtp_api_http/src/lib.rs
user: login: neekolas
body: What are the impacts of the jitter here?
created_at: 2025-04-09T00:29:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,26 +66,16 @@ pub struct XmtpHttpApiClient {
     host_url: String,
     app_version: String,
     libxmtp_version: String,
+    limiter: Arc<Limiter>,
     stats: ApiStats,
     identity_stats: IdentityStats,
 }
 
 impl XmtpHttpApiClient {
-    pub fn new(host_url: String, app_version: String) -> Result<Self, HttpClientError> {
-        let client = reqwest_builder();
-        let libxmtp_version = env!("CARGO_PKG_VERSION").to_string();
-        let mut headers = header::HeaderMap::new();
-        headers.insert("x-libxmtp-version", libxmtp_version.parse()?);
-        let client = client.default_headers(headers).build()?;
-
-        Ok(XmtpHttpApiClient {
-            http_client: client,
-            host_url,
-            app_version,
-            libxmtp_version,
-            stats: ApiStats::default(),
-            identity_stats: IdentityStats::default(),
-        })
+    /// Wait for any rate limit
+    async fn wait_for_ready(&self) {
+        let jitter = Jitter::up_to(Duration::from_secs(5));
path: xmtp_api_http/src/lib.rs
user: login: insipx
body: from the docs:

```
When polled, the returned future either resolves immediately (in the case where the rate limiter allows it), or else triggers an asynchronous delay, after which the rate limiter is polled again. This means that the future might resolve at some later time (depending on what other measurements are made on the rate limiter).

This method allows for a randomized additional delay between polls of the rate limiter, which can help reduce the likelihood of thundering herd effects if multiple tasks try to wait on the same rate limiter.
```

so it seems like another randomized value of up to 5 seconds is added to the delay, which is one minute
created_at: 2025-04-09T00:32:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,26 +66,16 @@ pub struct XmtpHttpApiClient {
     host_url: String,
     app_version: String,
     libxmtp_version: String,
+    limiter: Arc<Limiter>,
     stats: ApiStats,
     identity_stats: IdentityStats,
 }
 
 impl XmtpHttpApiClient {
-    pub fn new(host_url: String, app_version: String) -> Result<Self, HttpClientError> {
-        let client = reqwest_builder();
-        let libxmtp_version = env!("CARGO_PKG_VERSION").to_string();
-        let mut headers = header::HeaderMap::new();
-        headers.insert("x-libxmtp-version", libxmtp_version.parse()?);
-        let client = client.default_headers(headers).build()?;
-
-        Ok(XmtpHttpApiClient {
-            http_client: client,
-            host_url,
-            app_version,
-            libxmtp_version,
-            stats: ApiStats::default(),
-            identity_stats: IdentityStats::default(),
-        })
+    /// Wait for any rate limit
+    async fn wait_for_ready(&self) {
+        let jitter = Jitter::up_to(Duration::from_secs(5));
path: xmtp_api_http/src/lib.rs
user: login: neekolas
body: I guess what I'm wondering is if it can add a delay to calls when you aren't already rate limited
created_at: 2025-04-09T00:35:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,26 +66,16 @@ pub struct XmtpHttpApiClient {
     host_url: String,
     app_version: String,
     libxmtp_version: String,
+    limiter: Arc<Limiter>,
     stats: ApiStats,
     identity_stats: IdentityStats,
 }
 
 impl XmtpHttpApiClient {
-    pub fn new(host_url: String, app_version: String) -> Result<Self, HttpClientError> {
-        let client = reqwest_builder();
-        let libxmtp_version = env!("CARGO_PKG_VERSION").to_string();
-        let mut headers = header::HeaderMap::new();
-        headers.insert("x-libxmtp-version", libxmtp_version.parse()?);
-        let client = client.default_headers(headers).build()?;
-
-        Ok(XmtpHttpApiClient {
-            http_client: client,
-            host_url,
-            app_version,
-            libxmtp_version,
-            stats: ApiStats::default(),
-            identity_stats: IdentityStats::default(),
-        })
+    /// Wait for any rate limit
+    async fn wait_for_ready(&self) {
+        let jitter = Jitter::up_to(Duration::from_secs(5));
path: xmtp_api_http/src/lib.rs
user: login: insipx
body: ah good catch, i didn't know but checking the implementation [here](https://docs.rs/governor/latest/src/governor/state/direct/future.rs.html#51) seems like the delay is only added if the limiter check fails/we are being rate limited
created_at: 2025-04-09T00:39:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,18 +36,22 @@ jobs:
           workspaces: |
             .
       - name: Start Docker containers
-        run: dev/up
+        run: dev/docker/up-v3
       - name: Install emscripten toolchains
         run: |
           git clone https://github.com/emscripten-core/emsdk.git
           cd emsdk
           ./emsdk install latest
           ./emsdk activate latest
       - name: Build WebAssembly Packages
+        env:
+          RUSTFLAGS: --cfg tracing_unstable -Ctarget-feature=+bulk-memory,+mutable-globals,+atomics --cfg getrandom_backend="wasm_js"
         run: |
           source ./emsdk/emsdk_env.sh
           cargo build --locked --tests --release --target wasm32-unknown-unknown -p xmtp_id -p xmtp_mls -p xmtp_api_http -p xmtp_cryptography -p xmtp_common -p bindings_wasm -p xmtp_api_d14n -p xmtp_db
       - name: test with chrome
+        env:
path: .github/workflows/test-webassembly.yml
user: login: macroscopeapp[bot]
body: In the `test with chrome` step, the environment variables are defined in two separate `env` blocks. This could lead to the `RUSTFLAGS` value (set in the first block) being overridden or ignored when the second block (with just `CHROMEDRIVER`) is executed. Consider merging these into a single `env` block so that both `RUSTFLAGS` and `CHROMEDRIVER` are applied. If this behavior is intentional, you might consider adding documentation to explain the rationale.

```diff
+          CHROMEDRIVER: "chromedriver"
-         env:
-           CHROMEDRIVER: "chromedriver"
```
> 🚀 Reply to ask Macroscope to explain or update this suggestion.

> 👍 Helpful? React to give us feedback.
created_at: 2025-04-09T00:53:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: insipx
created_at: 2025-04-09T00:05:15Z
body: * **#1839** <a href="https://app.graphite.dev/github/pr/xmtp/libxmtp/1839?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/xmtp/libxmtp/1839?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: macroscopeapp[bot]
created_at: 2025-04-09T00:06:17Z
body: ### Replace application-level cooldown mechanism with transport-level rate limiting in XMTP API clients
* Implements transport-level rate limiting using the `governor` crate in [xmtp_api_http/src/lib.rs](https://github.com/xmtp/libxmtp/pull/1839/files#diff-7e1515ecc32ccaeb4f793f7caf1e1d05f4b47383a19d2e179784d79b9c192bcf) and [xmtp_api_grpc/src/lib.rs](https://github.com/xmtp/libxmtp/pull/1839/files#diff-d41c5465ecd03eb11155f12c50b649b1dea37dda23cd5fa0b53d9d7f8da58f7f)
* Removes cooldown functionality from retry mechanism in [common/src/retry.rs](https://github.com/xmtp/libxmtp/pull/1839/files#diff-1fc729bbb038af084238293889e90354fb5eca3dfd6641183f6afcacfd945c24)
* Adds `rate_per_minute` configuration method to `ApiBuilder` trait in [xmtp_proto/src/api_client.rs](https://github.com/xmtp/libxmtp/pull/1839/files#diff-7e862515880042ad7a89178c4850f4edd3b1a449ebd83515598088bd845fe67b)
* Updates WebAssembly build configuration to use `wasm_js` backend for `getrandom` crate across build scripts and CI workflows
* Makes `XmtpHttpApiClient::new` calls asynchronous in WebAssembly bindings

#### 📍Where to Start
Start with the rate limiting implementation in [xmtp_api_http/src/lib.rs](https://github.com/xmtp/libxmtp/pull/1839/files#diff-7e1515ecc32ccaeb4f793f7caf1e1d05f4b47383a19d2e179784d79b9c192bcf) which shows the core changes to implement transport-level rate limiting using the `governor` crate.

----

_[Macroscope](https://app.macroscope.com) summarized 894e97c._
author_association: CONTRIBUTOR
user: login: macroscopeapp[bot]
created_at: 2025-04-09T00:35:19Z
body: ### Changes in d4a23f0

- Modified `RUSTFLAGS` environment variable in CI workflow by removing `-D warnings` flag and adding WebAssembly feature flags `+bulk-memory`, `+mutable-globals`, `+atomics`, and configuring `getrandom` crate to use `wasm_js` backend


----

_[Macroscope](https://app.macroscope.com) summarized d4a23f0._
author_association: CONTRIBUTOR