Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdk/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ pub use solana::{
build_exact_svm_transfer_transaction, derive_associated_token_address, get_recent_blockhash,
ExactSvmTransfer, ExactSvmTransferOptions,
};
pub use websocket::{TinyPlaceWebSocket, WebSocketConnection, WsAuth};
pub use websocket::{ReconnectPolicy, TinyPlaceWebSocket, WebSocketConnection, WsAuth};
pub use x402_standard::{
build_exact_svm_payment_payload, decode_payment_required, decode_settlement_response,
encode_payment_signature, select_exact_svm_requirement, X402PaymentPayload,
Expand Down
198 changes: 192 additions & 6 deletions sdk/rust/src/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@
//! URL-borne credentials (directory-write query params, or the agent
//! `Authorization` as an `authorization=` param) are still appended, so the same
//! handle authenticates against backends that only read the query string.
//!
//! A dropped stream is re-established transparently by [`WebSocketConnection::recv`]
//! under a [`ReconnectPolicy`], whose defaults match the TypeScript SDK's
//! `reconnect` / `reconnectInterval` / `maxReconnectAttempts` options. Each
//! attempt re-runs the full dial, so the upgrade carries a **freshly signed**
//! credential — replaying the original request would present a stale
//! timestamp/nonce and be rejected with a 401.

use std::sync::Arc;
use std::time::Duration;

use futures_util::{SinkExt as _, StreamExt as _};
use tokio::net::TcpStream;
Expand Down Expand Up @@ -40,14 +48,63 @@ pub enum WsAuth {
Directory,
}

/// How a stream that drops mid-flight is re-established.
///
/// Defaults mirror the TypeScript SDK's `TinyPlaceWebSocketOptions`: reconnect
/// enabled, a 3s pause between attempts, and at most 10 attempts.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ReconnectPolicy {
/// Whether a dropped stream is re-dialled at all.
pub enabled: bool,
/// How long to wait before each attempt.
pub interval: Duration,
/// How many consecutive attempts to make before giving up.
///
/// The counter resets as soon as a re-established stream carries *any*
/// frame — a keepalive ping counts — so this bounds a run of consecutive
/// failures rather than the lifetime total. A quiet stream still resets it;
/// only a socket that produces nothing at all before dropping again does
/// not.
pub max_attempts: u32,
/// How long a single re-dial may take before it counts as a failed attempt.
///
/// `connect_async` has no timeout of its own, so a black-holed TCP connect
/// or a handshake the server never answers would otherwise hang `recv`
/// indefinitely and leave `max_attempts` bounding nothing.
pub connect_timeout: Duration,
}

impl Default for ReconnectPolicy {
fn default() -> Self {
Self {
enabled: true,
interval: Duration::from_millis(3_000),
max_attempts: 10,
connect_timeout: Duration::from_secs(10),
}
}
}

impl ReconnectPolicy {
/// A policy that never reconnects: `recv` reports the drop instead.
pub fn disabled() -> Self {
Self {
enabled: false,
..Self::default()
}
}
}

/// An un-connected WebSocket handle. Cheap to build; opens the socket on
/// [`connect`](TinyPlaceWebSocket::connect).
#[derive(Clone)]
pub struct TinyPlaceWebSocket {
origin: String,
request_uri: String,
signer: Option<Arc<dyn Signer>>,
public_key: Option<String>,
auth: WsAuth,
reconnect: ReconnectPolicy,
}

impl TinyPlaceWebSocket {
Expand All @@ -66,9 +123,31 @@ impl TinyPlaceWebSocket {
signer,
public_key,
auth,
reconnect: ReconnectPolicy::default(),
}
}

/// Override how a dropped stream is re-established.
///
/// ```no_run
/// # use tinyplace::{ReconnectPolicy, TinyPlaceClient};
/// # async fn run(client: TinyPlaceClient) -> tinyplace::Result<()> {
/// // Give up immediately, surfacing the drop to the caller.
/// let mut conn = client
/// .inbox
/// .stream()
/// .with_reconnect(ReconnectPolicy::disabled())
/// .connect()
/// .await?;
/// # let _ = conn.recv().await;
/// # Ok(())
/// # }
/// ```
pub fn with_reconnect(mut self, reconnect: ReconnectPolicy) -> Self {
self.reconnect = reconnect;
self
}

/// The fully-qualified WebSocket URL that would be opened, with auth applied.
/// Exposed for testing and diagnostics.
pub async fn signed_url(&self) -> Result<String> {
Expand Down Expand Up @@ -105,7 +184,27 @@ impl TinyPlaceWebSocket {
}

/// Open the WebSocket and return a connection to read/write messages.
///
/// This is a single attempt: a refused or unauthenticated upgrade returns
/// the error rather than retrying, so a misconfigured client fails fast.
/// [`ReconnectPolicy`] governs what happens to an *established* stream that
/// later drops.
pub async fn connect(&self) -> Result<WebSocketConnection> {
let stream = self.dial().await?;
Ok(WebSocketConnection {
inner: stream,
dialer: self.clone(),
attempts: 0,
last_dial_error: None,
})
}

/// Run one full dial: sign the URL and the upgrade headers, then handshake.
///
/// Every reconnect goes through here rather than replaying a stored request:
/// [`sign_websocket_upgrade`] binds a timestamp and nonce, so a replayed
/// upgrade would be rejected as stale.
async fn dial(&self) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>> {
let url = self.signed_url().await?;
let mut request = url
.as_str()
Expand All @@ -121,33 +220,120 @@ impl TinyPlaceWebSocket {
let (stream, _response) = connect_async(request)
.await
.map_err(|error| Error::WebSocket(error.to_string()))?;
Ok(WebSocketConnection { inner: stream })
Ok(stream)
}
}

/// An open WebSocket connection. Read JSON messages with [`recv`](Self::recv),
/// push with [`send`](Self::send), and shut down with [`close`](Self::close).
pub struct WebSocketConnection {
inner: WebSocketStream<MaybeTlsStream<TcpStream>>,
/// The handle this was opened from, kept so a drop can be re-dialled.
dialer: TinyPlaceWebSocket,
/// Consecutive reconnect attempts since the stream last carried a frame.
attempts: u32,
/// Why the most recent re-dial failed, reported in preference to the older
/// error that started the reconnect run.
last_dial_error: Option<Error>,
}

impl WebSocketConnection {
/// Await the next message, parsed as JSON. Returns `None` when the stream
/// closes. Ping/pong control frames are handled transparently and skipped.
/// Await the next message, parsed as JSON. Ping/pong control frames are
/// handled transparently and skipped.
///
/// If the stream drops, it is re-dialled per the handle's
/// [`ReconnectPolicy`] and the wait resumes — transparently to the caller.
/// Returns `None` once the stream has closed and reconnecting is disabled or
/// exhausted. A transport error is likewise reported only after reconnecting
/// has been given up on.
///
/// Note that a reconnect re-subscribes: anything the server published while
/// the socket was down is not replayed, and a stream that opens with a
/// snapshot frame will deliver a fresh one.
pub async fn recv(&mut self) -> Option<Result<serde_json::Value>> {
loop {
match self.inner.next().await {
Some(Ok(Message::Text(text))) => {
self.mark_healthy();
return Some(serde_json::from_str(&text).map_err(Error::from));
}
Some(Ok(Message::Binary(bytes))) => {
self.mark_healthy();
return Some(serde_json::from_slice(&bytes).map_err(Error::from));
}
Some(Ok(Message::Close(_))) | None => return None,
Some(Ok(_)) => continue,
Some(Err(error)) => return Some(Err(Error::WebSocket(error.to_string()))),
Some(Ok(Message::Close(_))) | None => {
if self.reconnect().await {
continue;
}
return None;
Comment on lines +264 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Reconnect on permanent close codes

Message::Close(_) is matched without inspecting the close frame's code, so a server-initiated close carrying a permanent-error code (e.g. 4001 Unauthorized, 4003 Forbidden, 1008 Policy Violation) triggers up to max_attempts reconnect attempts at full interval spacing — 30 seconds of retries by default for a token that is already expired. Only transient codes like 1001 (Going Away) or an unexpected 1006 (Abnormal Closure) are good candidates for reconnect; permanent ones will all fail for the same reason and just delay the caller in noticing.

A minimal guard would match Some(Ok(Message::Close(Some(frame)))) and skip reconnect when frame.code is in the application-error range (4000–4999) or other well-known permanent codes before falling through to the current reconnect path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — accurate about the code, but I am going to decline this one, and I want to be explicit about why so a maintainer can overrule me.

The specific example does not hold for this SDK. Every reconnect re-runs the full dial and re-signs the credential (sign_websocket_upgrade binds a fresh timestamp and nonce), so an expired token is precisely the case where retrying is correct — the new signature may well succeed. That was the main argument for the guard.

More importantly, I do not know what close codes this backend actually sends. The auth rejection path documented in e2e_docker.rs is a 401 on the upgrade, which never produces a close frame at all — it fails in dial() and burns an attempt. I could not find a case where the backend emits an app-range close code. Implementing an allowlist against guessed codes risks the worse failure: not reconnecting when we should, which is silent.

The cost of being wrong in the current direction is bounded and visible — at most max_attempts × interval of futile retries. The cost of a wrong allowlist is a stream that quietly stops.

Happy to add this the moment the backend documents its close-code semantics, or if someone who knows them wants to specify the set.

}
Some(Ok(_)) => {
// A control frame is not handed to the caller, but it is
// still proof the replacement socket works — and on a quiet
// stream (an inbox with no new items) the server's keepalive
// pings are the *only* traffic. Resetting solely on data
// would let unrelated outages, days apart, accumulate until
// `max_attempts` was spent and the stream ended for good.
self.mark_healthy();
continue;
}
Some(Err(error)) => {
if self.reconnect().await {
continue;
}
// Prefer why the last re-dial failed over the older error
// that started the run: by now it describes the current
// state of the world, which is what a caller can act on.
return Some(Err(self
.last_dial_error
.take()
.unwrap_or_else(|| Error::WebSocket(error.to_string()))));
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}
}

/// Record that the socket is carrying traffic: the retry budget for the
/// *next* outage starts over.
///
/// Deliberately not "reset on open", which is what the TS SDK does: a server
/// that accepts a connection and immediately drops it would then be retried
/// forever. Requiring at least one frame keeps `max_attempts` a real bound
/// on a genuinely broken endpoint.
fn mark_healthy(&mut self) {
self.attempts = 0;
self.last_dial_error = None;
}

/// Re-dial the stream, reporting whether a fresh one was established.
///
/// Pauses `interval` *before* each attempt so a server that drops
/// connections instantly cannot be hammered, and bounds each attempt by
/// `connect_timeout` so a stalled handshake cannot hang `recv` forever.
async fn reconnect(&mut self) -> bool {
let policy = self.dialer.reconnect;
if !policy.enabled {
return false;
}
while self.attempts < policy.max_attempts {
self.attempts += 1;
tokio::time::sleep(policy.interval).await;
match tokio::time::timeout(policy.connect_timeout, self.dialer.dial()).await {
Ok(Ok(stream)) => {
self.inner = stream;
self.last_dial_error = None;
return true;
}
Ok(Err(error)) => self.last_dial_error = Some(error),
Err(_) => {
self.last_dial_error = Some(Error::WebSocket(format!(
"reconnect timed out after {:?}",
policy.connect_timeout
)))
}
}
}
false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Send a JSON message to the server.
Expand Down
Loading
Loading