WireframeClient provides a first-class client runtime that mirrors the
server's framing and serialization layers, with a builder that configures the
serializer, codec settings, and socket options before connecting.[^44] Use
ClientCodecConfig to align max_frame_length with the server's
buffer_capacity, and apply SocketOptions when TCP tuning is required, such
as TCP_NODELAY or buffer size adjustments.
Client configuration reference
| Surface | API | Default | Use when |
|---|---|---|---|
| Serializer | WireframeClient::builder().serializer(...) |
BincodeSerializer |
Server and client need a non-default serialization contract. |
| Frame codec settings | codec_config(ClientCodecConfig) |
4-byte big-endian prefix and 1024-byte max frame length | Server buffer_capacity or protocol limits differ from defaults. |
| Socket tuning | socket_options(SocketOptions) |
OS defaults | Low-latency tuning (TCP_NODELAY) or keepalive policy is required. |
| Preamble payload | with_preamble(T) |
Disabled | Protocol negotiation must happen before framed payload traffic. |
| Preamble timeout | preamble_timeout(Duration) |
Disabled unless configured | Connection setup should fail fast on stalled preamble exchange. |
| Setup hook | on_connection_setup(...) |
Disabled | Per-connection state is needed for metrics or lifecycle coupling. |
| Teardown hook | on_connection_teardown(...) |
Disabled | Close should flush counters or release stateful resources. |
| Error hook | on_error(...) |
Disabled | Transport and decode failures must be routed to observability. |
| Before-send hook | before_send(...) |
Disabled | Inspect or mutate serialized bytes before every outgoing frame. |
| After-receive hook | after_receive(...) |
Disabled | Inspect or mutate raw bytes after every incoming frame is read. |
| Tracing config | tracing_config(TracingConfig) |
INFO connect/close, DEBUG data ops, timing off | Customize tracing span levels and per-command timing. |
| Pool connect | connect_pool(addr, ClientPoolConfig) |
Disabled | Warm socket reuse or bounded socket fan-out is required. |
| Pool size | ClientPoolConfig::pool_size(n) |
4 |
More than one warm physical socket should be maintained. |
| Per-socket admission | max_in_flight_per_socket(n) |
1 |
More than one caller may queue work against the same warm socket. |
| Idle recycle | idle_timeout(Duration) |
600s |
Idle sockets should be replaced before they become stale. |
use std::{net::SocketAddr, time::Duration};
use wireframe::{
app::Envelope,
client::{ClientCodecConfig, SocketOptions},
correlation::CorrelatableFrame,
message::Message,
WireframeClient,
};
#[derive(bincode::Encode, bincode::BorrowDecode)]
struct Login {
username: String,
}
#[derive(bincode::Encode, bincode::BorrowDecode, Debug, PartialEq)]
struct LoginAck {
username: String,
}
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let codec = ClientCodecConfig::default().max_frame_length(2048);
let socket = SocketOptions::default()
.nodelay(true)
.keepalive(Some(Duration::from_secs(30)));
let mut client = WireframeClient::builder()
.codec_config(codec)
.socket_options(socket)
.connect(addr)
.await?;
let login = Login {
username: "guest".to_string(),
};
let request = Envelope::new(1, None, login.to_bytes()?);
let response: Envelope = client.call_correlated(request).await?;
let (ack, _) = LoginAck::from_bytes(response.payload_bytes())?;
assert_eq!(ack.username, "guest");
assert!(response.correlation_id().is_some());
For screen readers: the following sequence diagram shows the client lifecycle for connect, optional preamble exchange, message I/O, error handling, and teardown.
Run the client example against the echo server:
# terminal 1
cargo run --example echo --features examples
# terminal 2
cargo run --example client_echo_login --features examples
Client pools
Use connect_pool when one warm socket is too little, but opening a new TCP
connection for every request is too expensive. WireframeClientPool keeps a
bounded set of warm sockets, preserves preamble state on reuse, recycles idle
sockets after the configured timeout, and can schedule blocked logical sessions
fairly through PoolHandle.
use std::{net::SocketAddr, time::Duration};
use wireframe::client::{ClientPoolConfig, PoolFairnessPolicy, WireframeClient};
#[derive(bincode::Encode, bincode::Decode)]
struct Ping(u8);
#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq)]
struct Pong(u8);
# #[tokio::main]
# async fn main() -> Result<(), wireframe::client::ClientError> {
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let pool = WireframeClient::builder()
.connect_pool(
addr,
ClientPoolConfig::default()
.pool_size(2)
.max_in_flight_per_socket(2)
.idle_timeout(Duration::from_secs(30))
.fairness_policy(PoolFairnessPolicy::RoundRobin),
)
.await?;
let lease = pool.acquire().await?;
let pong: Pong = lease.call(&Ping(1)).await?;
assert_eq!(pong, Pong(1));
pool.close().await;
# Ok(())
# }
Pooled leases forward the common request methods instead of exposing a mutable
reference to a long-lived WireframeClient. That keeps actual socket I/O
serialized per physical connection while still allowing multiple callers to be
admitted against the same warm slot. Treat max_in_flight_per_socket as an
admission budget, not as a guarantee of parallel writes on one TCP stream.
Create a PoolHandle when one logical session needs repeated pooled access and
that session should participate in the configured fairness policy over time:
# use std::net::SocketAddr;
use wireframe::client::{ClientPoolConfig, PoolFairnessPolicy, PoolHandle, WireframeClient};
#[derive(bincode::Encode, bincode::Decode)]
struct Ping(u8);
#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq)]
struct Pong(u8);
# #[tokio::main]
# async fn main() -> Result<(), wireframe::client::ClientError> {
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let pool = WireframeClient::builder()
.connect_pool(
addr,
ClientPoolConfig::default()
.pool_size(1)
.fairness_policy(PoolFairnessPolicy::RoundRobin),
)
.await?;
let mut session_a: PoolHandle<_, _, _> = pool.handle();
let mut session_b = pool.handle();
let pong_a: Pong = session_a.call(&Ping(1)).await?;
let pong_b: Pong = session_b.call(&Ping(2)).await?;
assert_eq!(pong_a, Pong(1));
assert_eq!(pong_b, Pong(2));
# Ok(())
# }
Use pool.handle() instead of repeated pool.acquire() when a long-lived
workflow should receive fair turns relative to other workflows. Fairness
policies order blocked handles; they do not create a second queue outside the
pool or bypass its back-pressure. If all permits are busy, the waiting handle
still blocks until capacity returns.
Continue using PooledClientLease for explicit split-phase work such as
send() followed later by receive(). PoolHandle does not pin a logical
session to one socket and does not demultiplex arbitrary responses across
shared handles.
Preamble callbacks and setup hooks run per physical socket creation. Warm reuse keeps the existing preamble state; idle recycle closes the old socket and creates a fresh one, which reruns preamble and setup.
Client preamble exchange
The client builder supports an optional preamble exchange before framing
begins. Use with_preamble to send a preamble immediately after TCP connect,
and register callbacks for success or failure scenarios.[^47]
use std::{net::SocketAddr, time::Duration};
use futures::FutureExt;
use wireframe::{
preamble::read_preamble,
WireframeClient,
};
#[derive(bincode::Encode, bincode::BorrowDecode)]
struct ClientHello {
version: u16,
}
#[derive(bincode::BorrowDecode)]
struct ServerAck {
accepted: bool,
}
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let client = WireframeClient::builder()
.with_preamble(ClientHello { version: 1 })
.preamble_timeout(Duration::from_secs(5))
.on_preamble_success(|_preamble, stream| {
async move {
// Read server acknowledgement
let (ack, leftover) = read_preamble::<_, ServerAck>(stream)
.await
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
if !ack.accepted {
return Err(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"server rejected preamble",
));
}
Ok(leftover) // Return any leftover bytes for framing layer
}
.boxed()
})
.on_preamble_failure(|err, _stream| {
async move {
eprintln!("Preamble exchange failed: {err}");
Ok(())
}
.boxed()
})
.connect(addr)
.await?;
The success callback receives the sent preamble and a mutable reference to the
TCP stream, enabling bidirectional preamble negotiation. Any bytes read beyond
the server's response must be returned as "leftover" bytes so they can be
replayed before the framing layer begins. The failure callback runs when the
preamble exchange fails (timeout, I/O error, or encode error) and can log
diagnostics or send an error response before the connection closes. In the
current implementation, callback read failures surface as
ClientError::PreambleRead, timeout expiry surfaces as
ClientError::PreambleTimeout, and write-side failures from
write_preamble(...) are wrapped by ClientError::PreambleEncode because the
helper returns bincode::EncodeError.
Client lifecycle hooks
The client builder supports lifecycle hooks that mirror the server's hook system, enabling consistent instrumentation across both client and server.[^48]
- Setup hook (
on_connection_setup): Invoked once after the TCP connection is established and preamble exchange (if configured) succeeds. Returns connection-specific state stored for the connection's lifetime. - Teardown hook (
on_connection_teardown): Invoked whenclose()is called. Receives the state produced by the setup hook for cleanup. - Error hook (
on_error): Invoked when errors occur during send, receive, or call operations. Receives a reference to the error for logging or metrics.
use std::{net::SocketAddr, sync::Arc};
use std::sync::atomic::{AtomicUsize, Ordering};
use wireframe::client::WireframeClient;
struct SessionState {
request_count: AtomicUsize,
}
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let client = WireframeClient::builder()
.on_connection_setup(|| async {
SessionState {
request_count: AtomicUsize::new(0),
}
})
.on_connection_teardown(|state: SessionState| async move {
println!(
"Session ended after {} requests",
state.request_count.load(Ordering::SeqCst)
);
})
.on_error(|err| async move {
eprintln!("Client error: {err}");
})
.connect(addr)
.await?;
// Use the client...
client.close().await; // Teardown hook invoked here
The setup hook runs after connection establishment (and preamble exchange if configured). The teardown hook only runs if a setup hook was configured and successfully produced state. The error hook is independent and can be configured without a setup hook.
Client request hooks
The client builder supports request hooks that fire on every outgoing and incoming frame, enabling symmetric instrumentation with the server middleware stack.[^52]
- Before-send hook (
before_send): Invoked after serialization, before each frame is written to the transport. Receives a&mut Vec<u8>of the serialized bytes, allowing inspection or mutation (e.g., prepending an authentication token, incrementing a metrics counter). - After-receive hook (
after_receive): Invoked after each frame is read from the transport, before deserialization. Receives a&mut BytesMutof the raw frame bytes, allowing inspection or mutation before the deserializer processes them.
Multiple hooks of the same kind may be registered; they execute in registration
order. Hooks are synchronous Fn closures—users who need mutable state should
capture an Arc<AtomicUsize> or Arc<Mutex<_>> in the closure.
use std::{net::SocketAddr, sync::Arc};
use std::sync::atomic::{AtomicUsize, Ordering};
use wireframe::client::WireframeClient;
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let send_counter = Arc::new(AtomicUsize::new(0));
let recv_counter = Arc::new(AtomicUsize::new(0));
let sc = send_counter.clone();
let rc = recv_counter.clone();
let mut client = WireframeClient::builder()
.before_send(move |_bytes: &mut Vec<u8>| {
sc.fetch_add(1, Ordering::SeqCst);
})
.after_receive(move |_bytes: &mut bytes::BytesMut| {
rc.fetch_add(1, Ordering::SeqCst);
})
.connect(addr)
.await?;
Request hooks fire on all message paths: send, send_envelope,
receive_envelope, call_correlated, call_streaming, and ResponseStream
polling. Clients configured without request hooks behave identically to clients
that have none registered.
Outbound streaming sends
The client supports sending large request bodies as multiple frames using
send_streaming. The caller provides a protocol-defined frame header and an
AsyncRead body source; the helper reads chunks, prepends the header to each
chunk, and emits framed packets over the transport.
use std::net::SocketAddr;
use wireframe::client::{SendStreamingConfig, WireframeClient};
# #[tokio::main]
# async fn main() -> Result<(), wireframe::client::ClientError> {
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid address");
let mut client = WireframeClient::builder().connect(addr).await?;
let header = [0xCA, 0xFE, 0xBA, 0xBE];
let body = vec![0u8; 4096];
let config = SendStreamingConfig::default()
.with_chunk_size(512)
.with_timeout(std::time::Duration::from_secs(5));
let outcome = client.send_streaming(&header, &body[..], config).await?;
println!("sent {} frames", outcome.frames_sent());
# Ok(())
# }
SendStreamingConfig controls chunking and timeout behaviour:
with_chunk_size(usize)— maximum body bytes per frame. When not set, the chunk size is derived asmax_frame_length - header.len().with_timeout(Duration)— timeout for the entire operation. If the timeout elapses,std::io::ErrorKind::TimedOutis returned and no further frames are emitted. Any frames already sent remain sent; callers must assume the operation may have been partially successful.
SendStreamingOutcome reports the number of frames emitted via
frames_sent(). The error hook is invoked on all failure paths, consistent
with other client send methods.[^53]
Client tracing
The client emits tracing spans around every operation. Span levels and
per-command timing are configurable via TracingConfig, which is passed to the
builder with tracing_config(). When no tracing subscriber is installed, all
instrumentation is zero-cost.
Default span levels: INFO for lifecycle operations (connect, close) and
DEBUG for data operations (send, receive, call, call_streaming).
Per-command timing is disabled by default.
use std::net::SocketAddr;
use tracing::Level;
use wireframe::client::{TracingConfig, WireframeClient};
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
// Enable timing for connect and call, set all spans to TRACE level.
let config = TracingConfig::default()
.with_all_levels(Level::TRACE)
.with_connect_timing(true)
.with_call_timing(true);
let mut client = WireframeClient::builder()
.tracing_config(config)
.connect(addr)
.await?;
When timing is enabled for an operation, a DEBUG-level event recording
elapsed_us is emitted when the operation completes (on both success and error
paths). Streaming responses emit per-frame DEBUG events with
stream.frames_received and a termination event with stream.frames_total.
Clients configured without tracing_config() use the default configuration and
behave identically to the pre-tracing API.
Client message API with correlation identifiers
The client provides envelope-aware messaging APIs that work with the Packet
trait, supporting automatic correlation ID generation and validation. These
methods complement the basic send, receive, and call methods that operate
on raw Message types.
Correlation ID generation: Each client maintains an atomic counter for
generating unique correlation IDs. The next_correlation_id method returns the
next ID, which is useful when managing correlation manually.
Envelope-aware methods:
send_envelope<P: Packet>(envelope: P)— Sends an envelope, auto-generating a correlation ID if not present. Returns the correlation ID used.receive_envelope<P: Packet>()— Receives and deserializes the next frame as the specified packet type.call_correlated<P: Packet>(request: P)— Sends a request with auto- generated correlation ID, receives the response, and validates that the response's correlation ID matches the request. ReturnsClientError::CorrelationMismatchif the IDs differ.
use std::net::SocketAddr;
use wireframe::{
app::{Envelope, Packet},
client::{ClientError, WireframeClient},
};
# async fn example() -> Result<(), ClientError> {
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let mut client = WireframeClient::builder()
.connect(addr)
.await?;
// Create an envelope without a correlation ID.
let request = Envelope::new(1, None, vec![1, 2, 3]);
// call_correlated auto-generates a correlation ID, sends the request,
// receives the response, and validates the correlation ID matches.
let response: Envelope = client.call_correlated(request).await?;
// The response's correlation ID matches the auto-generated request ID.
assert!(response.correlation_id().is_some());
# Ok(())
# }
For more control over correlation, use send_envelope and receive_envelope
separately:
use wireframe::app::{Envelope, Packet};
# use wireframe::client::{ClientError, WireframeClient};
# async fn example(client: &mut WireframeClient) -> Result<(), ClientError> {
// Auto-generate correlation ID when sending.
let envelope = Envelope::new(1, None, b"payload".to_vec());
let correlation_id = client.send_envelope(envelope).await?;
// Receive the response.
let response: Envelope = client.receive_envelope().await?;
// Manually verify correlation if needed.
assert_eq!(response.correlation_id(), Some(correlation_id));
# Ok(())
# }
The CorrelationMismatch error provides diagnostic information when validation
fails:
use wireframe::client::ClientError;
match client.call_correlated(request).await {
Ok(response) => {
// Handle successful response.
}
Err(ClientError::CorrelationMismatch { expected, received }) => {
eprintln!(
"Correlation mismatch: expected {:?}, received {:?}",
expected, received
);
}
Err(e) => {
// Handle other errors.
}
}
Client request/response error mapping
Client request/response operations (receive, call, receive_envelope, and
call_correlated) map transport and decode failures to WireframeError
variants exposed through ClientError::Wireframe:
- Transport failures map to
WireframeError::Io. - Decode failures map to
WireframeError::ProtocolwithClientProtocolError::Deserialize.
use wireframe::{
ClientError,
ClientProtocolError,
WireframeError,
};
match client.call(&request).await {
Ok(response) => {
// Handle successful response.
}
Err(ClientError::Wireframe(WireframeError::Io(err))) => {
eprintln!("transport failure: {err}");
}
Err(ClientError::Wireframe(WireframeError::Protocol(
ClientProtocolError::Deserialize(err),
))) => {
eprintln!("decode failure: {err}");
}
Err(other) => {
// Handle serialize/preamble/correlation failures.
eprintln!("other client error: {other}");
}
}