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.
Qué puedes construir
Sección titulada «Qué puedes construir»- 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.
¿Qué crate elijo?
Sección titulada «¿Qué crate elijo?»| 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óAlgunas cosas en las que fijarte:
127.0.0.1significa “solo este ordenador”. Usa0.0.0.0cuando 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 queprintln!funciona sobre la pantalla: añade el\n.read_linelee hasta llegar a él. - Cualquier llamada de red puede fallar, así que cada una devuelve un
Resulty usamos?. &streamsirve para leer y para escribir, porque&TcpStreamimplementa tantoReadcomoWrite.
Muchos clientes con hilos
Sección titulada «Muchos clientes con hilos»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 palabrasCuando 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.
El mismo servidor con tokio
Sección titulada «El mismo servidor con tokio»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 palabrasinto_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 en un minuto
Sección titulada «UDP en un minuto»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 seatokio::net::UdpSocket funciona igual, con .await.
Timeouts y errores de red
Sección titulada «Timeouts y errores de red»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: ConnectionRefusedla lectura se rindió tras 200 mse.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 y JSON con reqwest
Sección titulada «HTTP y JSON con reqwest»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:
cargo new api-clientcd api-clientcargo add reqwest --features jsoncargo add tokio --features fullcargo 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.
GET y POST con JSON
Sección titulada «GET y POST con JSON»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.
Timeouts y errores con reqwest
Sección titulada «Timeouts y errores con reqwest»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 bytesserver said 404 Not Foundcould not connect: error sending request for url (http://127.0.0.1:1/)gave up after 2 secondsis_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.
Errores comunes
Sección titulada «Errores comunes»- 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_stringespera 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::netostd::thread::sleepdentro de una tarea de tokio bloquea un hilo de trabajo entero. En código async, usatokio::netytokio::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.
Práctica
Sección titulada «Práctica»1. Un servidor calculadora
Sección titulada «1. Un servidor calculadora»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 = 5mul 4 5 = 20add 2 x = error: no es un númerodiv 8 2 = error: operación desconocida divTener 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 completedfirst open one: delectus aut autemUn array JSON corresponde a un Vec, y serde rellena cada elemento por ti. Tu struct solo lista los campos que necesitas.
Resumen
Sección titulada «Resumen»std::nette da TCP bloqueante (TcpListener,TcpStream) y UDP (UdpSocket) sin crates extra.- Haz
bindal puerto0para obtener cualquier puerto libre, y léelo después conlocal_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 que aprendiste
Sección titulada «Comprueba lo que aprendiste»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
Para leer más
Sección titulada «Para leer más»- std::net — la biblioteca estándar de Rust (en inglés):
TcpListener,TcpStream,UdpSockety las direcciones, con ejemplos. - Building a Single-Threaded Web Server — El libro de Rust (en inglés): el proyecto final del libro construye un servidor HTTP sobre
std::nety luego le añade un pool de hilos. También tienes el libro completo traducido por la comunidad. - Tutorial de Tokio (en inglés): TCP async paso a paso, desde lanzar tareas hasta delimitar mensajes (framing).
- reqwest en docs.rs (en inglés): el cliente, las peticiones, JSON, timeouts y el módulo
blocking. - Web clients — Rust Cookbook (en inglés): recetas cortas para peticiones HTTP, APIs y descargas.