diff --git a/src/clob/client.rs b/src/clob/client.rs index a335b61..fdfa318 100644 --- a/src/clob/client.rs +++ b/src/clob/client.rs @@ -522,17 +522,62 @@ impl ClientInner { signer: &S, nonce: Option, ) -> Result { - match self.create_api_key(signer, nonce).await { + // DERIVE-FIRST. Polymarket's `POST /auth/api-key` is NOT + // idempotent: each successful call (different timestamp => + // different EIP-712 signature) registers a NEW api-key for + // the wallet, but only ONE — the canonical one — is + // recognised by the authenticated user-WSS channel + // (`wss://ws-subscriptions-clob.polymarket.com/ws/user`). + // Subscribing with a non-canonical key results in a silent + // server-side reset (`Connection reset without closing + // handshake`) within ~30 ms. + // + // Empirical verification 2026-05-07 (same wallet, nonce=1): + // - Rust create_then_derive: returned `29184383-…`, + // valid for REST balance/orders, rejected on WSS. + // - `GET /auth/derive-api-key`: returned `57f6d806-…` + // for the same wallet+nonce, accepted on WSS. + // + // `derive_api_key` returns the canonical key that POSTed in + // the past. Only when no key has ever been created for this + // wallet+nonce do we POST to create one — then re-derive to + // pick up the canonical (which on a fresh wallet+nonce is + // the one we just created, but on a wallet that has been + // POST-spammed in the past is whichever Polymarket nominated + // as canonical). + match self.derive_api_key(signer, nonce).await { Ok(creds) => Ok(creds), - Err(err) if err.kind() == ErrorKind::Status => { - // Only fall back to derive_api_key for HTTP status errors (server responded - // with an error, e.g., key already exists). Propagate network/internal errors. + Err(err) if is_not_found(&err) => { + // No canonical key yet (HTTP 404). Create one, then + // re-derive to make sure we return whatever + // Polymarket marks canonical on subsequent calls. + // + // Critical: scoped to 404 specifically. Falling back + // on ANY status error (500/503/429/etc.) would issue + // a `create_api_key` POST on every transient + // derive-endpoint blip, registering a NEW non-canonical + // key each time — exactly the bug this PR exists to + // fix. Other Status errors propagate to the caller. + self.create_api_key(signer, nonce).await?; self.derive_api_key(signer, nonce).await } Err(err) => Err(err), } } +} +/// Returns true iff `err` is an HTTP error wrapping a 404 status. +/// Anything else (other 4xx/5xx, network failure, parse error) is +/// treated as transient and propagated by `create_or_derive_api_key`. +fn is_not_found(err: &crate::error::Error) -> bool { + if err.kind() != ErrorKind::Status { + return false; + } + err.downcast_ref::() + .is_some_and(|s| s.status_code == crate::error::StatusCode::NOT_FOUND) +} + +impl ClientInner { async fn create_headers(&self, signer: &S, nonce: Option) -> Result { let chain_id = signer.chain_id().ok_or(Error::validation( "Chain id not set, be sure to provide one on the signer", diff --git a/src/clob/ws/client.rs b/src/clob/ws/client.rs index 091ce69..2f0c5a8 100644 --- a/src/clob/ws/client.rs +++ b/src/clob/ws/client.rs @@ -643,6 +643,18 @@ impl ChannelResources { } } +impl Drop for ChannelResources { + fn drop(&mut self) { + // Break the Arc cycle that the resubscribe task creates by + // holding `Arc`. After abort, that task + // drops its reference, the manager's strong count falls to + // zero, its `ConnectionManager` clone is released, and the + // connection-loop guard inside `ConnectionManager` aborts the + // connection task in turn. + self.subscriptions.abort_reconnection_handler(); + } +} + fn normalize_base_endpoint(endpoint: &str) -> String { let trimmed = endpoint.trim_end_matches('/'); if let Some(stripped) = trimmed.strip_suffix("/ws/market") { diff --git a/src/clob/ws/subscription.rs b/src/clob/ws/subscription.rs index d01aa2d..135f232 100644 --- a/src/clob/ws/subscription.rs +++ b/src/clob/ws/subscription.rs @@ -5,13 +5,14 @@ use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, PoisonError, RwLock}; +use std::sync::{Arc, OnceLock, PoisonError, RwLock}; use std::time::Instant; use async_stream::try_stream; use dashmap::{DashMap, Entry}; use futures::Stream; use tokio::sync::broadcast::error::RecvError; +use tokio::task::JoinHandle; use super::interest::{InterestTracker, MessageInterest}; use super::types::request::SubscriptionRequest; @@ -84,6 +85,13 @@ pub struct SubscriptionManager { /// Track if custom features were enabled for any market subscription /// (enables `best_bid_ask`, `new_market`, `market_resolved` messages) custom_features_enabled: AtomicBool, + /// `JoinHandle` for the resubscribe task spawned by + /// [`Self::start_reconnection_handler`]. Stored here so callers + /// holding the owning `Arc` can cancel it via + /// [`Self::abort_reconnection_handler`] — the resubscribe task + /// captures `Arc`, which would otherwise create a cycle that + /// keeps the manager alive forever. + resub_handle: OnceLock>, } impl SubscriptionManager { @@ -101,48 +109,72 @@ impl SubscriptionManager { subscribed_markets: DashMap::new(), last_auth: Arc::new(RwLock::new(None)), custom_features_enabled: AtomicBool::new(false), + resub_handle: OnceLock::new(), } } /// Start the reconnection handler that re-subscribes on connection recovery. + /// + /// Idempotent and leak-safe: the spawn happens inside + /// [`OnceLock::get_or_init`] so a second call neither double-spawns + /// the task nor drops a `JoinHandle` whose task still owns + /// `Arc` (Tokio drop merely detaches; a detached resubscribe + /// task would re-create the very Arc cycle this module exists to + /// break). The cloned `Arc` lives inside the outer + /// closure and is only consumed if the closure runs. pub fn start_reconnection_handler(self: &Arc) { let this = Arc::clone(self); - - tokio::spawn(async move { - let mut state_rx = this.connection.state_receiver(); - let mut was_connected = state_rx.borrow().is_connected(); - - loop { - // Wait for next state change - if state_rx.changed().await.is_err() { - // Channel closed, connection manager is gone - break; - } - - let state = *state_rx.borrow_and_update(); - - match state { - ConnectionState::Connected { .. } => { - if was_connected { - // Reconnect to subscriptions - #[cfg(feature = "tracing")] - tracing::debug!("WebSocket reconnected, re-establishing subscriptions"); - this.resubscribe_all(); - } - was_connected = true; - } - ConnectionState::Disconnected => { - // Connection permanently closed + self.resub_handle.get_or_init(move || { + tokio::spawn(async move { + let mut state_rx = this.connection.state_receiver(); + let mut was_connected = state_rx.borrow().is_connected(); + + loop { + // Wait for next state change + if state_rx.changed().await.is_err() { + // Channel closed, connection manager is gone break; } - _ => { - // Other states are no-op + + let state = *state_rx.borrow_and_update(); + + match state { + ConnectionState::Connected { .. } => { + if was_connected { + // Reconnect to subscriptions + #[cfg(feature = "tracing")] + tracing::debug!( + "WebSocket reconnected, re-establishing subscriptions" + ); + this.resubscribe_all(); + } + was_connected = true; + } + ConnectionState::Disconnected => { + // Connection permanently closed + break; + } + _ => { + // Other states are no-op + } } } - } + }) }); } + /// Abort the resubscribe task spawned by + /// [`Self::start_reconnection_handler`]. Must be called by whoever + /// owns `Arc` before they drop their last + /// reference, otherwise the spawned task keeps a strong `Arc` + /// and the manager — together with its underlying connection — + /// leaks for the rest of the process. + pub fn abort_reconnection_handler(&self) { + if let Some(handle) = self.resub_handle.get() { + handle.abort(); + } + } + /// Re-send subscription requests for all tracked assets and markets. fn resubscribe_all(&self) { // Collect all subscribed assets @@ -563,3 +595,128 @@ impl SubscriptionManager { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::ws::config::Config; + + /// Without [`SubscriptionManager::abort_reconnection_handler`], the + /// task spawned by [`SubscriptionManager::start_reconnection_handler`] + /// holds an `Arc` clone forever, and the + /// manager's `ConnectionManager` clone keeps the connection-loop + /// `state_tx` / `sender_tx` alive. Both spawned tasks then leak for + /// the rest of the process. This regression test exercises the + /// public abort method and proves the strong count drops to zero. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn abort_reconnection_handler_breaks_arc_cycle() { + let interest = Arc::new(InterestTracker::new()); + // Loopback at port 1 will never accept; the connection task will + // spin with backoff. We don't care about traffic — only that the + // task can be cancelled via the abort path and releases its Arc. + let connection = ConnectionManager::new( + "ws://127.0.0.1:1/never-connects".to_owned(), + Config::default(), + Arc::clone(&interest), + ) + .expect("ConnectionManager::new should not fail before connect"); + let subs = Arc::new(SubscriptionManager::new(connection, interest)); + subs.start_reconnection_handler(); + + let weak = Arc::downgrade(&subs); + subs.abort_reconnection_handler(); + drop(subs); + + // Yield until the aborted resubscribe task drops its Arc clone. + // 50 yields × 10 ms is plenty for `JoinHandle::abort` to take + // effect; if the cycle were intact, this would never converge. + for _ in 0..50 { + if weak.upgrade().is_none() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!( + "SubscriptionManager not dropped within 500ms after abort — \ + Arc cycle still present" + ); + } + + /// Mirror test for the connection-loop guard: after the last + /// `ConnectionManager` clone drops, the spawned connection task is + /// aborted via the internal `_task_guard: Arc`. + /// We can't observe the task directly, but we can prove the Arc + /// chain unwinds by checking the manager itself drops. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn connection_manager_clones_release_when_subscription_manager_aborted() { + let interest = Arc::new(InterestTracker::new()); + let connection = ConnectionManager::new( + "ws://127.0.0.1:1/never-connects".to_owned(), + Config::default(), + Arc::clone(&interest), + ) + .expect("ConnectionManager::new should not fail before connect"); + let subs = Arc::new(SubscriptionManager::new(connection.clone(), interest)); + subs.start_reconnection_handler(); + + // Drop the local clone so `subs.connection` is the only userland + // ConnectionManager clone left (plus any held internally by the + // spawned tasks). + drop(connection); + + let weak = Arc::downgrade(&subs); + subs.abort_reconnection_handler(); + drop(subs); + + for _ in 0..50 { + if weak.upgrade().is_none() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("ConnectionManager not released within 500ms after subscription abort"); + } + + /// Calling `start_reconnection_handler` twice must not spawn a + /// second resubscribe task. The first implementation of the leak + /// fix dropped the second `JoinHandle` after `OnceLock::set` + /// returned `Err`, but Tokio drop on a `JoinHandle` only detaches + /// — the orphan task kept its `Arc` clone forever and the + /// Arc cycle reappeared on the second call. `get_or_init` makes + /// the spawn run at most once and the captured `Arc` is + /// only consumed when the closure runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn start_reconnection_handler_called_twice_does_not_leak() { + let interest = Arc::new(InterestTracker::new()); + let connection = ConnectionManager::new( + "ws://127.0.0.1:1/never-connects".to_owned(), + Config::default(), + Arc::clone(&interest), + ) + .expect("ConnectionManager::new should not fail before connect"); + let subs = Arc::new(SubscriptionManager::new(connection, interest)); + + subs.start_reconnection_handler(); + // Second call mirrors what userland would do if it forgot it + // had already started the handler. Must be a no-op, not a + // task-leak. + subs.start_reconnection_handler(); + + let weak = Arc::downgrade(&subs); + subs.abort_reconnection_handler(); + drop(subs); + + for _ in 0..50 { + if weak.upgrade().is_none() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!( + "SubscriptionManager not dropped — second start_reconnection_handler \ + call leaked an unaborted task holding Arc" + ); + } +} diff --git a/src/rtds/client.rs b/src/rtds/client.rs index a5940d1..d2646c4 100644 --- a/src/rtds/client.rs +++ b/src/rtds/client.rs @@ -65,6 +65,23 @@ struct ClientInner { connection: ConnectionManager, /// Subscription manager for handling subscriptions subscriptions: Arc, + /// Aborts the resubscribe task when the last `ClientInner` is dropped, + /// breaking the Arc cycle that task creates by holding + /// `Arc`. Lives in its own field rather than as + /// a `Drop` impl on `ClientInner` so that `authenticate()` can still + /// destructure the inner. + resub_abort_guard: ResubAbortGuard, +} + +/// RAII handle that aborts the spawned resubscribe task on drop. +/// Carries an extra `Arc` clone solely to call +/// [`SubscriptionManager::abort_reconnection_handler`] from `Drop`. +struct ResubAbortGuard(Arc); + +impl Drop for ResubAbortGuard { + fn drop(&mut self) { + self.0.abort_reconnection_handler(); + } } impl Client { @@ -76,6 +93,8 @@ impl Client { // Start reconnection handler to re-subscribe on connection recovery subscriptions.start_reconnection_handler(); + let resub_abort_guard = ResubAbortGuard(Arc::clone(&subscriptions)); + Ok(Self { inner: Arc::new(ClientInner { state: Unauthenticated, @@ -83,6 +102,7 @@ impl Client { endpoint: endpoint.to_owned(), connection, subscriptions, + resub_abort_guard, }), }) } @@ -110,6 +130,7 @@ impl Client { endpoint: inner.endpoint, connection: inner.connection, subscriptions: inner.subscriptions, + resub_abort_guard: inner.resub_abort_guard, }), }) } @@ -325,6 +346,7 @@ impl Client> { endpoint: inner.endpoint, connection: inner.connection, subscriptions: inner.subscriptions, + resub_abort_guard: inner.resub_abort_guard, }), }) } diff --git a/src/rtds/subscription.rs b/src/rtds/subscription.rs index 233d4b4..a7283e6 100644 --- a/src/rtds/subscription.rs +++ b/src/rtds/subscription.rs @@ -3,13 +3,14 @@ reason = "Subscription types deliberately include the module name for clarity" )] -use std::sync::{Arc, PoisonError, RwLock}; +use std::sync::{Arc, OnceLock, PoisonError, RwLock}; use std::time::Instant; use async_stream::try_stream; use dashmap::{DashMap, Entry}; use futures::Stream; use tokio::sync::broadcast::error::RecvError; +use tokio::task::JoinHandle; use super::error::RtdsError; use super::types::request::{Subscription, SubscriptionRequest}; @@ -68,6 +69,13 @@ pub struct SubscriptionManager { /// Subscribed topics with reference counts (for multiplexing) subscribed_topics: DashMap, last_auth: RwLock>, + /// `JoinHandle` for the resubscribe task spawned by + /// [`Self::start_reconnection_handler`]. Stored here so callers + /// holding the owning `Arc` can cancel it via + /// [`Self::abort_reconnection_handler`] — the resubscribe task + /// captures `Arc`, which would otherwise create a cycle that + /// keeps the manager alive forever. + resub_handle: OnceLock>, } impl SubscriptionManager { @@ -79,45 +87,67 @@ impl SubscriptionManager { active_subs: DashMap::new(), subscribed_topics: DashMap::new(), last_auth: RwLock::new(None), + resub_handle: OnceLock::new(), + } + } + + /// Abort the resubscribe task spawned by + /// [`Self::start_reconnection_handler`]. Must be called by whoever + /// owns `Arc` before they drop their last + /// reference, otherwise the spawned task keeps a strong `Arc` + /// and the manager — together with its underlying connection — + /// leaks for the rest of the process. + pub fn abort_reconnection_handler(&self) { + if let Some(handle) = self.resub_handle.get() { + handle.abort(); } } /// Start the reconnection handler that re-subscribes on connection recovery. + /// + /// Idempotent and leak-safe: the spawn happens inside + /// [`OnceLock::get_or_init`] so a second call neither double-spawns + /// the task nor drops a `JoinHandle` whose task still owns + /// `Arc` (Tokio drop merely detaches; a detached resubscribe + /// task would re-create the very Arc cycle this module exists to + /// break). The cloned `Arc` lives inside the outer + /// closure and is only consumed if the closure runs. pub fn start_reconnection_handler(self: &Arc) { let this = Arc::clone(self); - - tokio::spawn(async move { - let mut state_rx = this.connection.state_receiver(); - let mut was_connected = state_rx.borrow().is_connected(); - - loop { - // Wait for next state change - if state_rx.changed().await.is_err() { - // Channel closed, connection manager is gone - break; - } - - let state = *state_rx.borrow_and_update(); - - match state { - ConnectionState::Connected { .. } => { - if was_connected { - // Reconnect to subscriptions - #[cfg(feature = "tracing")] - tracing::debug!("RTDS reconnected, re-establishing subscriptions"); - this.resubscribe_all(); - } - was_connected = true; - } - ConnectionState::Disconnected => { - // Connection permanently closed + self.resub_handle.get_or_init(move || { + tokio::spawn(async move { + let mut state_rx = this.connection.state_receiver(); + let mut was_connected = state_rx.borrow().is_connected(); + + loop { + // Wait for next state change + if state_rx.changed().await.is_err() { + // Channel closed, connection manager is gone break; } - _ => { - // Other states are no-op + + let state = *state_rx.borrow_and_update(); + + match state { + ConnectionState::Connected { .. } => { + if was_connected { + // Reconnect to subscriptions + #[cfg(feature = "tracing")] + tracing::debug!("RTDS reconnected, re-establishing subscriptions"); + this.resubscribe_all(); + } + was_connected = true; + } + ConnectionState::Disconnected => { + // Connection permanently closed + break; + } + _ => { + // Other states are no-op + } } } - } + }) }); } diff --git a/src/ws/connection.rs b/src/ws/connection.rs index 56b8c69..737a79c 100644 --- a/src/ws/connection.rs +++ b/src/ws/connection.rs @@ -5,6 +5,7 @@ use std::fmt::Debug; use std::marker::PhantomData; +use std::sync::Arc; use std::time::Instant; use backoff::backoff::Backoff as _; @@ -13,6 +14,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use tokio::net::TcpStream; use tokio::sync::{broadcast, mpsc, watch}; +use tokio::task::JoinHandle; use tokio::time::{interval, sleep, timeout}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; @@ -100,10 +102,26 @@ where sender_tx: mpsc::UnboundedSender, /// Broadcast sender for incoming messages broadcast_tx: broadcast::Sender, + /// Aborts the spawned connection-loop task when the last `ConnectionManager` + /// clone is dropped. Without this, the task holds `state_tx` and + /// `sender_tx` indefinitely and reconnects forever even after every + /// public handle to the manager has been dropped. + _task_guard: Arc, /// Phantom data for unused type parameters _phantom: PhantomData

, } +/// Aborts the wrapped `JoinHandle` when dropped. Used as the cancellation +/// trigger for spawned WebSocket background tasks. +#[derive(Debug)] +struct AbortOnDropHandle(JoinHandle<()>); + +impl Drop for AbortOnDropHandle { + fn drop(&mut self) { + self.0.abort(); + } +} + impl ConnectionManager where M: DeserializeOwned + Debug + Clone + Send + 'static, @@ -125,7 +143,7 @@ where let broadcast_tx_clone = broadcast_tx.clone(); let state_tx_clone = state_tx.clone(); - tokio::spawn(async move { + let handle = tokio::spawn(async move { Self::connection_loop( connection_endpoint, connection_config, @@ -142,6 +160,7 @@ where state_rx, sender_tx, broadcast_tx, + _task_guard: Arc::new(AbortOnDropHandle(handle)), _phantom: PhantomData, }) } diff --git a/tests/auth.rs b/tests/auth.rs index 7eb0f75..a974db3 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -192,16 +192,16 @@ async fn derive_api_key_should_succeed() -> anyhow::Result<()> { } #[tokio::test] -async fn create_or_derive_api_key_should_succeed() -> anyhow::Result<()> { +async fn create_or_derive_api_key_returns_canonical_via_derive_without_creating() -> anyhow::Result<()> { + // Hot path: a key for this wallet+nonce already exists (Polymarket + // returned it on derive). The fix is to NEVER POST in this case — + // POST creates a new non-canonical key that Polymarket rejects on + // the user-WSS channel. Verified empirically 2026-05-07. let server = MockServer::start(); let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); let client = Client::new(&server.base_url(), Config::default())?; - let mock = server.mock(|when, then| { - when.method(httpmock::Method::POST).path("/auth/api-key"); - then.status(StatusCode::NOT_FOUND); - }); - let mock2 = server.mock(|when, then| { + let derive_mock = server.mock(|when, then| { when.method(httpmock::Method::GET) .path("/auth/derive-api-key") .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); @@ -211,13 +211,57 @@ async fn create_or_derive_api_key_should_succeed() -> anyhow::Result<()> { "secret": SECRET })); }); + // POST must NOT be hit when derive succeeds. + let create_mock = server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/auth/api-key"); + then.status(StatusCode::OK).json_body(json!({"apiKey":"BAD","passphrase":"x","secret":"y"})); + }); let credentials = client.create_or_derive_api_key(&signer, None).await?; assert_eq!(credentials.key(), API_KEY); - mock.assert(); - mock2.assert(); + derive_mock.assert(); + create_mock.assert_hits(0); + Ok(()) +} +#[tokio::test] +async fn create_or_derive_api_key_creates_then_re_derives_when_no_canonical_exists() -> anyhow::Result<()> { + // Cold path: derive 404s (no key has ever been created for this + // wallet+nonce). Then we POST to create one, then re-derive to + // pick up whatever Polymarket nominates as canonical (which on a + // fresh wallet+nonce is the one we just created, but on a + // previously-spammed wallet may differ). + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let client = Client::new(&server.base_url(), Config::default())?; + + let derive_404 = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::NOT_FOUND); + }); + let create_ok = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/auth/api-key") + .header(POLY_ADDRESS, signer.address().to_string().to_lowercase()); + then.status(StatusCode::OK).json_body(json!({ + "apiKey": API_KEY.to_string(), + "passphrase": PASSPHRASE, + "secret": SECRET + })); + }); + + let err = client.create_or_derive_api_key(&signer, None).await; + // After create succeeds, we re-derive — derive_404 still 404s in + // this mock setup, so the call propagates the error. The + // important invariant: POST was reached, proving derive-first + // didn't short-circuit. A real Polymarket would return the key + // on the second derive. + assert!(err.is_err()); + derive_404.assert_hits(2); // before-create + after-create + create_ok.assert(); Ok(()) } @@ -238,6 +282,45 @@ async fn create_or_derive_api_key_should_propagate_network_errors() -> anyhow::R Ok(()) } +#[tokio::test] +async fn create_or_derive_api_key_propagates_non_404_status_without_creating() -> anyhow::Result<()> { + // The fallback to `create_api_key` is scoped to HTTP 404 only. + // A transient 500/503/429 from the derive endpoint MUST NOT + // trigger create — that would register a new non-canonical key + // on every blip, defeating the purpose of derive-first. Pinned + // because Cursor Bugbot caught this on the original patch + // (`err.kind() == ErrorKind::Status` matched ANY status). + for code in [ + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::SERVICE_UNAVAILABLE, + ] { + let server = MockServer::start(); + let signer = LocalSigner::from_str(PRIVATE_KEY)?.with_chain_id(Some(POLYGON)); + let client = Client::new(&server.base_url(), Config::default())?; + + let derive_err = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/auth/derive-api-key"); + then.status(code); + }); + let create_mock = server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/auth/api-key"); + then.status(StatusCode::OK) + .json_body(json!({"apiKey": API_KEY, "passphrase": PASSPHRASE, "secret": SECRET})); + }); + + let err = client + .create_or_derive_api_key(&signer, None) + .await + .expect_err(&format!("status {code} must propagate, not trigger create")); + assert_eq!(err.kind(), Kind::Status, "got {err:?} for code {code}"); + derive_err.assert(); + create_mock.assert_hits(0); + } + Ok(()) +} + #[test] fn credentials_secret_accessor_should_return_secret() { let credentials = Credentials::new(API_KEY, SECRET.to_owned(), PASSPHRASE.to_owned()); diff --git a/tests/clob.rs b/tests/clob.rs index 9bc094a..f699678 100644 --- a/tests/clob.rs +++ b/tests/clob.rs @@ -1590,7 +1590,9 @@ mod authenticated { assert_eq!(signed_order.owner.to_string(), API_KEY.to_string()); assert_eq!(signed_order.order_type, OrderType::GTC); mock.assert(); - mock2.assert_calls(2); + // Was 2 (POST create + GET derive each fetched server time); + // derive-first only hits /time once. + mock2.assert_calls(1); Ok(()) } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 65df334..29288f4 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -88,7 +88,9 @@ pub async fn create_authenticated(server: &MockServer) -> anyhow::Result