For Rust developers who know Tokio and want a simple request/reply service over ZeroMQ
You will build a small port lookup service over TCP on localhost. A REP socket answers questions like "what runs on port 22?" and REQ clients ask them. Along the way you will break the send/receive rule on purpose, share one server between three clients, give up on a slow reply with RCVTIMEO and recover when the server goes away.
Before you start
You need Rust with Cargo. rzmq comes from crates.io:
The default features cover everything here. Each step replaces src/main.rs with a complete program. The programs use Tokio's single-threaded runtime so the output comes out in the same order every time.
REQ/REP is the plain client/server pattern. A REQ socket sends a request and then waits for the reply. A REP socket receives a request and then sends the reply. The server is the stable end, so the REP socket binds and the client connects to it.
The server runs in its own Tokio task and answers a fixed number of requests before it returns. The client asks three questions, one at a time. Each send is followed by a recv that waits for the matching answer. Once the server task has finished, ctx.term() closes both sockets.
A REQ socket must alternate: send, receive, send, receive. A REP socket does the same the other way round: receive, then send. A call out of order returns ZmqError::InvalidState. This step breaks the order on purpose to show what that looks like.
Replace src/main.rs:
src/main.rs
userzmq::{Context, Msg, SocketType, ZmqError};constSERVICE:&str="tcp://127.0.0.1:5621";fntext(msg:&Msg)-> String{String::from_utf8_lossy(msg.data().unwrap_or_default()).into_owned()}#[tokio::main(flavor ="current_thread")]async fnmain()->Result<(), ZmqError>{let ctx =Context::new()?;let rep = ctx.socket(SocketType::Rep)?; rep.bind(SERVICE).await?;let req = ctx.socket(SocketType::Req)?; req.connect(SERVICE).await?; req.send(Msg::from_static(b"22")).await?;match req.send(Msg::from_static(b"53")).await {Err(e)=>println!("REQ second send: {e:?}"),Ok(())=>println!("REQ second send was accepted"),}match rep.send(Msg::from_static(b"too early")).await {Err(e)=>println!("REP send before recv: {e:?}"),Ok(())=>println!("REP send before recv was accepted"),}let request = rep.recv().await?;println!("REP got {}",text(&request)); rep.send(Msg::from_static(b"22 ssh")).await?;let reply = req.recv().await?;println!("REQ got {}",text(&reply)); ctx.term().await?;Ok(())}
Run it:
$cargo runREQ second send: InvalidState("REQ socket must call recv() before sending again")REP send before recv: InvalidState("REP socket must recv() a request before sending a reply")REP got 22REQ got 22 ssh
Both calls return the error and send nothing. The rejected 53 never reaches the server, which only sees 22. After the errors both sockets carry on normally. If you need several requests in flight at once, REQ/REP is the wrong pair; DEALER and ROUTER are the asynchronous versions.
A REP server can have many REQ clients connected at once. It takes their requests one at a time and each reply goes back to the client that asked. This step starts three clients, each in its own task with its own REQ socket. Each one asks two questions. The server now sleeps 50 ms per lookup to stand in for real work.
Replace src/main.rs:
src/main.rs
userzmq::{Context, Msg, Socket, SocketType, ZmqError};usestd::time::{Duration, Instant};constSERVICE:&str="tcp://127.0.0.1:5622";constCLIENTS:usize=3;fntext(msg:&Msg)-> String{String::from_utf8_lossy(msg.data().unwrap_or_default()).into_owned()}fnlookup(port:&str)->&'staticstr{match port {"22"=>"ssh","53"=>"dns","80"=>"http","443"=>"https","5432"=>"postgres",_=>"unknown",}}async fnserve(rep: Socket, requests:usize)->Result<(), ZmqError>{for_in0..requests {let request = rep.recv().await?;let port =text(&request);tokio::time::sleep(Duration::from_millis(50)).await;let answer =format!("{port}{}",lookup(&port)); rep.send(Msg::from_vec(answer.into_bytes())).await?;}Ok(())}async fnclient(req: Socket, ports: [&'staticstr; 2])->Result<Vec<String>, ZmqError>{letmut answers =Vec::new();for port in ports { req.send(Msg::from_vec(port.as_bytes().to_vec())).await?; answers.push(text(&req.recv().await?));}Ok(answers)}#[tokio::main(flavor ="current_thread")]async fnmain()->Result<(), ZmqError>{let ctx =Context::new()?;let rep = ctx.socket(SocketType::Rep)?; rep.bind(SERVICE).await?;let server =tokio::spawn(serve(rep,CLIENTS*2));let questions =[["22","53"],["80","443"],["5432","8080"]];let started =Instant::now();letmut clients =Vec::new();for ports in questions {let req = ctx.socket(SocketType::Req)?; req.connect(SERVICE).await?; clients.push(tokio::spawn(client(req, ports)));}for(id, handle)in clients.into_iter().enumerate(){let answers = handle.await.expect("client panicked")?;println!("client-{}: {}", id +1, answers.join(", "));} server.await.expect("server panicked")?;println!("6 lookups took {:.1}s", started.elapsed().as_secs_f64()); ctx.term().await?;Ok(())}
Each client task returns its answers and main prints them in client order. The order in which the server picks up the six requests can change from run to run, but every client gets the answers to its own questions.
Six lookups at 50 ms each take about 0.3 seconds. A REP socket works on one request at a time, so a slow lookup holds up every client queued behind it. When one slow request shouldn't hold up the rest, move to DEALER/ROUTER.
By default a REQ client waits in recv until the reply comes. The RCVTIMEO option puts a limit on that wait, in milliseconds. When it runs out, recv returns ZmqError::Timeout.
After a timeout the client throws its REQ socket away: it closes it with close() and opens a new one. That is the usual ZeroMQ recovery for a lost request. Socket setup moves into client_socket so the client can make a fresh one whenever it needs to.
In this step, looking up port 3306 takes 400 ms on the server and the client waits at most 250 ms. The server now prints a line each time it sends an answer.
Replace src/main.rs:
src/main.rs
userzmq::socket::RCVTIMEO;userzmq::{Context, Msg, Socket, SocketType, ZmqError};usestd::time::Duration;constSERVICE:&str="tcp://127.0.0.1:5623";constTIMEOUT_MS:i32=250;fntext(msg:&Msg)-> String{String::from_utf8_lossy(msg.data().unwrap_or_default()).into_owned()}fnlookup(port:&str)->&'staticstr{match port {"22"=>"ssh","53"=>"dns","80"=>"http","443"=>"https","3306"=>"mysql","5432"=>"postgres",_=>"unknown",}}async fnserve(rep: Socket, requests:usize)->Result<(), ZmqError>{for_in0..requests {let request = rep.recv().await?;let port =text(&request);if port =="3306"{tokio::time::sleep(Duration::from_millis(400)).await;}let answer =format!("{port}{}",lookup(&port)); rep.send(Msg::from_vec(answer.into_bytes())).await?;println!("server: answered {port}");}Ok(())}async fnclient_socket(ctx:&Context)->Result<Socket, ZmqError>{let req = ctx.socket(SocketType::Req)?; req.set_option(RCVTIMEO,TIMEOUT_MS).await?; req.connect(SERVICE).await?;Ok(req)}async fnask(req:&Socket, port:&str)->Result<String, ZmqError>{ req.send(Msg::from_vec(port.as_bytes().to_vec())).await?;Ok(text(&req.recv().await?))}#[tokio::main(flavor ="current_thread")]async fnmain()->Result<(), ZmqError>{let ctx =Context::new()?;let rep = ctx.socket(SocketType::Rep)?; rep.bind(SERVICE).await?;let server =tokio::spawn(serve(rep,3));letmut req =client_socket(&ctx).await?;println!("client: {}",ask(&req,"22").await?);matchask(&req,"3306").await {Ok(answer)=>println!("client: {answer}"),Err(ZmqError::Timeout)=>{println!("client: no reply for 3306 within {TIMEOUT_MS} ms"); req.close().await?; req =client_socket(&ctx).await?;}Err(e)=>returnErr(e),}println!("client: {}",ask(&req,"443").await?); server.await.expect("server panicked")?; ctx.term().await?;Ok(())}
Run it:
$cargo runserver: answered 22client: 22 sshclient: no reply for 3306 within 250 msserver: answered 3306server: answered 443client: 443 https
The server doesn't know the client gave up. It finishes the 3306 lookup, sends the answer toward a socket that has been closed and then takes the 443 request from the new socket. The new request waits behind the slow one, which is why the timeout has to be longer than the time left on the slow lookup.
The same recovery covers a server that dies in the middle of a request. The first server here answers one lookup, receives the next one and then closes its socket without replying. The client's recv hits RCVTIMEO, the client closes its REQ socket, a second server binds the same endpoint and the client retries on a fresh socket.
$cargo runserver: answered 22client: 22 sshserver: got 53 and crashedclient: no reply for 53 within 250 msserver: restartedserver: answered 53client: retry gave 53 dnsserver: answered 80client: 80 http
A retry means the server may see the same request twice: the first server received 53 before it died and the second one answered it again. That is harmless for a lookup. For a request that changes something, such as charging a card, the server needs a way to spot a repeat, for example a request ID it has already handled.
Every server in this tutorial answers a known number of requests and then returns, so shutdown is a matter of waiting for it and then terminating the context:
Context::term() closes every socket made from the context and waits for their background tasks to finish. That includes a REQ socket you replaced but never closed. Socket::close() shuts down one socket early, which is what the client uses to throw away a socket after a lost request. Errors go up with ? so a failed run exits with an error instead of hiding it.
REQ/REP keeps one request in flight per client. When a client needs several requests outstanding or a server needs to answer out of order, use DEALER for the client and ROUTER for the server. ROUTER hands you the sender's identity as the first frame so you can route each reply yourself.
RCVTIMEO only limits recv. SNDTIMEO puts the same kind of limit on send. A send that runs out of time also returns ZmqError::Timeout. A client that should never hang sets both in client_socket: