Ir al contenido

Redes en Rust: servidores TCP y clientes HTTP

Casi todos los programas que usas hablan con otro ordenador: una app de chat, un juego, el widget del tiempo. Por dentro, abren una conexión, envían unos cuantos bytes y leen la respuesta. Rust es muy bueno en esto. Es rápido, usa poca memoria y el compilador atrapa muchos de los errores que hacen que el código de red se caiga a las 3 de la madrugada.

En esta página vas a escribir un pequeño servidor y un cliente TCP, atender a varios clientes a la vez (primero con hilos y luego con tokio y async) y, después, llamar a APIs HTTP reales con reqwest. Los ejemplos de TCP se ejecutan aquí mismo, en el Playground de Rust. Los de reqwest necesitan internet, así que esos los ejecutas en tu ordenador con cargo run. Todos se compilaron y ejecutaron, y la salida que ves es real.

  • Servicios de red y proxies. Cloudflare construyó en Rust Pingora (en inglés), su framework de código abierto para proxies. La service mesh Linkerd (en inglés) también usa un proxy escrito en Rust.
  • APIs web y back ends, con axum sobre tokio. Mira Proyecto: Construir una API web.
  • Clientes de APIs y herramientas: una herramienta de línea de comandos que llama a GitHub, un bot, un script que comprueba si tus sitios web siguen funcionando.
  • Protocolos propios: un servidor de juegos, un servidor de chat, un dispositivo que envía lecturas de sensores por UDP.

Dónde cuesta más: el Rust async tiene una curva de aprendizaje real, y la primera compilación con crates de red tarda uno o dos minutos. Para un script de 10 líneas que llama a una sola API, puede que Python sea más rápido de escribir. Rust compensa cuando el programa tiene que ser rápido, funcionar durante meses o atender muchas conexiones.

Crate Para qué sirve Estilo Elígelo cuando
std::net Sockets TCP y UDP, incluidos en Rust bloqueante Aprendes, haces herramientas pequeñas o tienes pocas conexiones. Sin dependencias.
tokio Runtime async con TCP, UDP y temporizadores async async Muchas conexiones a la vez. La mayoría de los crates de red se basan en él.
reqwest Cliente HTTP cómodo (JSON, timeouts, TLS) async (bloqueante opcional) Llamas a APIs web. La primera opción habitual.
ureq Cliente HTTP pequeño y sencillo bloqueante No quieres nada de async ni de tokio.
hyper Motor HTTP de bajo nivel que hay debajo de reqwest y axum async Estás construyendo un framework o necesitas control total.
axum Framework web para servidores HTTP async Estás escribiendo una API web. Mira el proyecto con axum.

Todos son maduros y muy usados. Un buen camino: aprende las ideas con std::net y luego pasa a tokio, reqwest y axum.

Un servidor y un cliente TCP en un solo programa

Sección titulada «Un servidor y un cliente TCP en un solo programa»

TCP te da un stream (flujo) de bytes entre dos programas, como una llamada de teléfono. El servidor escucha en una dirección y un puerto. El cliente se conecta a él. A partir de ahí, los dos lados pueden leer y escribir.

Normalmente el servidor y el cliente son dos programas distintos. Aquí metemos los dos en uno, para que se ejecute en el Playground: el servidor corre en un hilo y main es el cliente.

use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn main() -> std::io::Result<()> {
// El puerto 0 significa "cualquier puerto libre". Luego preguntamos cuál nos tocó.
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
// El servidor: acepta un cliente, lee una línea y responde con otra línea.
let server = thread::spawn(move || -> std::io::Result<()> {
let (stream, _) = listener.accept()?;
let mut name = String::new();
BufReader::new(&stream).read_line(&mut name)?;
writeln!(&stream, "¡Hola, {}!", name.trim())?;
Ok(())
});
// El cliente: se conecta, envía una línea y lee la respuesta.
let stream = TcpStream::connect(addr)?;
writeln!(&stream, "Ferris")?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply)?;
print!("el cliente recibió: {reply}");
server.join().unwrap()?;
println!("el servidor terminó");
Ok(())
}

Salida:

el cliente recibió: ¡Hola, Ferris!
el servidor terminó
▶ Pruébalo tú mismo

Algunas cosas en las que fijarte:

  • 127.0.0.1 significa “solo este ordenador”. Usa 0.0.0.0 cuando otras máquinas tengan que llegar a tu servidor.
  • TCP no tiene ni idea de dónde termina un mensaje. Usamos una regla sencilla: un mensaje = una línea. writeln! funciona sobre un stream igual que println! funciona sobre la pantalla: añade el \n. read_line lee hasta llegar a él.
  • Cualquier llamada de red puede fallar, así que cada una devuelve un Result y usamos ?.
  • &stream sirve para leer y para escribir, porque &TcpStream implementa tanto Read como Write.

Un servidor de verdad no puede hacer esperar al cliente número 2 hasta que termine el cliente número 1. La respuesta clásica: un hilo por conexión. El bucle acepta un cliente, se lo pasa a un hilo nuevo y vuelve enseguida a accept.

Este servidor cuenta las palabras de cada línea que recibe. Tres clientes se conectan a la vez:

use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::thread;
// Lado del servidor: responde a cada línea con su número de palabras.
fn handle_client(stream: TcpStream) -> std::io::Result<()> {
for line in BufReader::new(&stream).lines() {
let words = line?.split_whitespace().count();
writeln!(&stream, "{words} palabras")?;
}
Ok(()) // el cliente cerró la conexión
}
// Lado del cliente: envía una línea y devuelve la respuesta.
fn ask(addr: SocketAddr, msg: &str) -> std::io::Result<String> {
let stream = TcpStream::connect(addr)?;
writeln!(&stream, "{msg}")?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply)?;
Ok(format!("{msg:?} -> {}", reply.trim()))
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
thread::spawn(move || {
for stream in listener.incoming() {
let stream = stream.unwrap();
thread::spawn(move || handle_client(stream)); // un hilo por cliente
}
});
// Tres clientes a la vez, cada uno en su propio hilo.
let clients = ["hola qué tal", "rust es divertido de aprender", "nos vemos pronto"]
.map(|msg| thread::spawn(move || ask(addr, msg)));
// Los esperamos en orden, para que la salida salga siempre igual.
for c in clients {
println!("{}", c.join().unwrap()?);
}
Ok(())
}

Salida:

"hola qué tal" -> 3 palabras
"rust es divertido de aprender" -> 5 palabras
"nos vemos pronto" -> 3 palabras
▶ Pruébalo tú mismo

Cuando un cliente suelta (drop) su TcpStream, la conexión se cierra, lines() termina y el hilo de ese cliente acaba. El bucle de accept no termina nunca, y eso es normal en un servidor. Aquí el programa simplemente se detiene cuando main devuelve. Este patrón es sencillo y funciona bien con decenas o cientos de clientes. Con miles, todos esos hilos gastan mucha memoria. Ahí es donde entra async.

Con tokio, cada cliente es una tarea (task) en lugar de un hilo. Una tarea es diminuta, así que un solo servidor puede mantener muchos miles de conexiones. Compárala con la versión con hilos de arriba: std::net pasa a ser tokio::net, thread::spawn pasa a ser tokio::spawn, y añades .await en cada sitio donde el programa espera a la red.

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
// El mismo servidor que cuenta palabras, pero cada cliente es una tarea barata, no un hilo.
async fn handle_client(stream: TcpStream) -> std::io::Result<()> {
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? {
let words = line.split_whitespace().count();
write_half.write_all(format!("{words} palabras\n").as_bytes()).await?;
}
Ok(())
}
async fn ask(addr: std::net::SocketAddr, msg: &str) -> std::io::Result<String> {
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(format!("{msg}\n").as_bytes()).await?;
let mut reply = String::new();
BufReader::new(stream).read_line(&mut reply).await?;
Ok(format!("{msg:?} -> {}", reply.trim()))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(handle_client(stream)); // una tarea por cliente
}
});
// Lanzamos tres clientes a la vez y luego esperamos a cada uno en orden.
let clients = ["hola qué tal", "rust es divertido de aprender", "nos vemos pronto"]
.map(|msg| tokio::spawn(ask(addr, msg)));
for c in clients {
println!("{}", c.await.unwrap()?);
}
Ok(())
}

Salida:

"hola qué tal" -> 3 palabras
"rust es divertido de aprender" -> 5 palabras
"nos vemos pronto" -> 3 palabras
▶ Pruébalo tú mismo

into_split() te da una mitad para leer y otra para escribir, así la lectura y la escritura pueden vivir en variables distintas. Igual que antes, el bucle de accept se ejecuta para siempre. Cuando main devuelve, tokio detiene todas las tareas.

UDP no tiene conexión. Envías mensajes sueltos (datagramas) a una dirección, y cada uno llega entero o no llega. No hay garantía de orden ni de entrega. Se usa en juegos, videollamadas, DNS y sensores, donde la velocidad importa más que perder un paquete. En std::net es un solo tipo, UdpSocket:

let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // un mensaje, enviado a una dirección
let mut buf = [0u8; 1500];
let (n, from) = socket.recv_from(&mut buf)?; // espera un mensaje de quien sea

tokio::net::UdpSocket funciona igual, con .await.

En una red, las cosas fallan todo el tiempo: nadie está escuchando, se cae el Wi-Fi, un servidor se queda colgado. Dos reglas mantienen sano tu programa: maneja cada error y nunca esperes para siempre. Por defecto, una lectura espera para siempre. set_read_timeout lo arregla:

use std::io::{ErrorKind, Read};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;
fn main() -> std::io::Result<()> {
// 1. Nadie está escuchando: la conexión falla al instante.
let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // soltado = cerrado
match TcpStream::connect(free_port) {
Ok(_) => println!("¿¡conectado?!"),
Err(e) => println!("falló la conexión: {:?}", e.kind()),
}
// 2. Un servidor que acepta, pero nunca responde.
let silent = TcpListener::bind("127.0.0.1:0")?;
let mut stream = TcpStream::connect(silent.local_addr()?)?;
stream.set_read_timeout(Some(Duration::from_millis(200)))?;
let mut buf = [0u8; 64];
match stream.read(&mut buf) {
Ok(n) => println!("se leyeron {n} bytes"),
// Linux informa WouldBlock, Windows informa TimedOut.
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
println!("la lectura se rindió tras 200 ms")
}
Err(e) => println!("otro error: {e}"),
}
Ok(())
}

Salida:

falló la conexión: ConnectionRefused
la lectura se rindió tras 200 ms
▶ Pruébalo tú mismo

e.kind() te da un ErrorKind (en inglés) sobre el que puedes hacer match. El nombre del tipo de error (ConnectionRefused, “conexión rechazada”) lo imprime Rust tal cual, en inglés. También existe TcpStream::connect_timeout para conexiones lentas. En tokio, envuelve cualquier future en tokio::time::timeout:

let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;
// Err(_) = tardó demasiado; Ok(inner) = la lectura terminó (inner es su propio Result)

HTTP es el idioma de la web, y funciona sobre TCP. Podrías escribir HTTP a mano sobre un TcpStream, pero un crate cliente se ocupa de los detalles por ti: HTTPS, redirecciones, cabeceras, compresión. Estos ejemplos necesitan acceso a internet y el crate reqwest, así que no se ejecutan en el Playground. Ejecútalos en tu ordenador:

Terminal window
cargo new api-client
cd api-client
cargo add reqwest --features json
cargo add tokio --features full
cargo add serde --features derive
[dependencies]
reqwest = { version = "0.13", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Vamos a usar JSONPlaceholder (en inglés), una API falsa y gratuita hecha para practicar. Sus datos de ejemplo están en un latín de relleno, por eso verás títulos como “delectus aut autem”. La primera compilación descarga y compila bastantes crates, así que dale un minuto.

reqwest::get(url).await?.text().await? te da el cuerpo de la respuesta como un String. Pero para JSON hay algo mejor: con la feature json, reqwest usa serde para convertir la respuesta directamente en tu struct, y para convertir tu struct en el cuerpo de la petición:

use serde::{Deserialize, Serialize};
// Solo se leen los campos que listas. Los campos JSON de más (como "userId") se ignoran.
#[derive(Deserialize)]
struct Todo {
id: u32,
title: String,
completed: bool,
}
#[derive(Serialize)]
struct NewTodo {
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
// GET: JSON -> tu struct
let todo: Todo = client
.get("https://jsonplaceholder.typicode.com/todos/1")
.send()
.await?
.json()
.await?;
println!("#{}: {} (done: {})", todo.id, todo.title, todo.completed);
// POST: tu struct -> JSON (esta API de prueba responde, pero no lo guarda de verdad)
let new = NewTodo { title: "Learn reqwest".to_string(), completed: false };
let response = client
.post("https://jsonplaceholder.typicode.com/todos")
.json(&new)
.send()
.await?;
println!("status: {}", response.status());
println!("{}", response.text().await?);
Ok(())
}
#1: delectus aut autem (done: false)
status: 201 Created
{
"title": "Learn reqwest",
"completed": false,
"id": 201
}

Crea un solo Client y reutilízalo. Mantiene las conexiones abiertas y las vuelve a usar, y eso hace que las peticiones siguientes sean más rápidas. .json(&new) también pone por ti la cabecera Content-Type: application/json.

¿No quieres async? ureq es un cliente bloqueante pequeño: ureq::get(url).call()?.body_mut().read_to_string()? te da el cuerpo como un String, sin necesidad de tokio.

reqwest no tiene timeout por defecto, así que un servidor atascado puede hacer que tu programa espere para siempre. Pon uno en el cliente. Y otra cosa: para reqwest, un 404 Not Found no es un error. La petición funcionó; la respuesta simplemente fue “no”. Llama a error_for_status() para convertir las respuestas 4xx y 5xx en errores:

use std::time::Duration;
// Convierte también las respuestas 4xx/5xx en errores, no solo los problemas de red.
async fn fetch(client: &reqwest::Client, url: &str) -> Result<String, reqwest::Error> {
let response = client.get(url).send().await?.error_for_status()?;
response.text().await
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
// Sin timeout, una petición a un servidor atascado puede esperar para siempre.
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()?;
// Un servidor local que acepta conexiones, pero nunca responde.
let silent = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let stuck = format!("http://{}/", silent.local_addr().unwrap());
let urls = [
"https://jsonplaceholder.typicode.com/todos/1",
"https://jsonplaceholder.typicode.com/todos/999999",
"http://127.0.0.1:1/",
&stuck,
];
for url in urls {
match fetch(&client, url).await {
Ok(body) => println!("ok, {} bytes", body.len()),
Err(e) if e.is_status() => println!("server said {}", e.status().unwrap()),
Err(e) if e.is_connect() => println!("could not connect: {e}"),
Err(e) if e.is_timeout() => println!("gave up after 2 seconds"),
Err(e) => println!("other error: {e}"),
}
}
Ok(())
}
ok, 83 bytes
server said 404 Not Found
could not connect: error sending request for url (http://127.0.0.1:1/)
gave up after 2 seconds

is_status(), is_connect() e is_timeout() te dicen qué tipo de fallo ocurrió, para que decidas qué hacer: reintentar más tarde, mostrar un mensaje o rendirte. En un programa más grande, anyhow facilita añadir contexto a estos errores.

  • Los dos lados se esperan mutuamente. El cliente llama a read_line, pero el servidor también está leyendo, porque al cliente se le olvidó el \n. Nadie envía nada y el programa se queda colgado. Los dos lados tienen que estar de acuerdo en dónde termina un mensaje (un salto de línea es la regla más fácil).
  • Leer “hasta el final” demasiado pronto. read_to_string espera hasta que el otro lado cierra la conexión. En una conexión que sigue abierta, nunca vuelve. Lee línea a línea en su lugar.
  • Bloquear dentro de código async. Usar std::net o std::thread::sleep dentro de una tarea de tokio bloquea un hilo de trabajo entero. En código async, usa tokio::net y tokio::time::sleep.
  • Olvidar los timeouts. Tus pruebas pasan en una red local rápida. Y luego un solo servidor lento lo congela todo. Pon timeouts desde el primer día.

Escribe un servidor que lea líneas como add 2 3 o mul 4 5 y responda con el resultado. Si la línea no es válida, responde con un mensaje corto error: .... Pruébalo desde main con una conexión y cuatro preguntas. Pista: divide la línea con split_whitespace(), convierte los números con parse::<i64>() y responde con writeln!.

Ver solución
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn calculate(line: &str) -> String {
let parts: Vec<&str> = line.split_whitespace().collect();
let [op, a, b] = parts[..] else {
return "error: usa <op> <a> <b>".to_string();
};
let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else {
return "error: no es un número".to_string();
};
match op {
"add" => (a + b).to_string(),
"mul" => (a * b).to_string(),
_ => format!("error: operación desconocida {op}"),
}
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
let server = thread::spawn(move || -> std::io::Result<()> {
let (stream, _) = listener.accept()?;
for line in BufReader::new(&stream).lines() {
writeln!(&stream, "{}", calculate(&line?))?;
}
Ok(())
});
let stream = TcpStream::connect(addr)?;
let mut replies = BufReader::new(&stream);
for question in ["add 2 3", "mul 4 5", "add 2 x", "div 8 2"] {
writeln!(&stream, "{question}")?;
let mut answer = String::new();
replies.read_line(&mut answer)?;
println!("{question} = {}", answer.trim());
}
drop(stream); // cerrar la conexión termina el bucle del servidor
server.join().unwrap()
}

Salida:

add 2 3 = 5
mul 4 5 = 20
add 2 x = error: no es un número
div 8 2 = error: operación desconocida div
▶ Pruébalo tú mismo

Tener la lógica en una función normal, calculate, significa que puedes probarla sin ninguna red. Las líneas let ... else devuelven antes de tiempo cuando la entrada no tiene la forma correcta.

2. Cuenta las tareas terminadas (en tu ordenador)

Sección titulada «2. Cuenta las tareas terminadas (en tu ordenador)»

Con reqwest, pide https://jsonplaceholder.typicode.com/todos?userId=1. Devuelve un array JSON de tareas pendientes (to-dos). Imprime cuántas hay, cuántas están completadas y el título de la primera que no lo está. Pista: deserializa en un Vec<Todo> y luego usa métodos de iterador como filter y find.

Ver solución
use serde::Deserialize;
#[derive(Deserialize)]
struct Todo {
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let todos: Vec<Todo> = reqwest::get("https://jsonplaceholder.typicode.com/todos?userId=1")
.await?
.error_for_status()?
.json()
.await?;
let done = todos.iter().filter(|t| t.completed).count();
println!("user 1 has {} to-dos, {done} completed", todos.len());
let first_open = todos.iter().find(|t| !t.completed).unwrap();
println!("first open one: {}", first_open.title);
Ok(())
}
user 1 has 20 to-dos, 11 completed
first open one: delectus aut autem

Un array JSON corresponde a un Vec, y serde rellena cada elemento por ti. Tu struct solo lista los campos que necesitas.

  • std::net te da TCP bloqueante (TcpListener, TcpStream) y UDP (UdpSocket) sin crates extra.
  • Haz bind al puerto 0 para obtener cualquier puerto libre, y léelo después con local_addr().
  • TCP es un flujo de bytes: decide dónde termina un mensaje, por ejemplo, un mensaje por línea.
  • Atiende a muchos clientes con un hilo por conexión, o con tareas de tokio cuando necesites miles.
  • reqwest (async) o ureq (bloqueante) llaman a APIs HTTP, y serde convierte el JSON en tus structs.
  • Pon siempre timeouts, y usa error_for_status() para que un 404 no parezca un éxito.

Comprueba lo aprendido

1. ¿Qué hace enlazar (bind) un TcpListener a 127.0.0.1:0?

2. ¿Por qué los ejemplos de esta página envían un \n al final de cada mensaje?

3. reqwest recibe una respuesta 404 Not Found. ¿Qué devuelve send().await?

4. Tu servidor tokio atiende a cada cliente con std::net y std::thread::sleep. ¿Cuál es el problema?

Puntuación: 0 / 4