La lección anterior terminó con un servidor TCP de inventario que entendía STOCK queso-curado\n y con una incomodidad: para cada nueva operación había que inventar un formato de texto, escribir un parser a cada lado y traducir errores a mano. Lo que un desarrollador de pedidos querría escribir es inventario.reservar_stock("queso-curado", 2) y obtener un resultado, sin pensar en bytes ni en sockets. Esa aspiración tiene nombre desde 1984: llamada a procedimiento remoto (RPC, Remote Procedure Call), y su versión orientada a objetos en Java es RMI (Remote Method Invocation).

En esta lección entenderemos qué hay dentro de una llamada remota (stubs, marshalling, IDL), por qué "como si fuera local" es una promesa que la red no puede cumplir del todo (retomando las falacias de 01-04), qué semánticas de invocación existen y por qué "exactamente una vez" es un mito. Implementaremos inventario como servidor XML-RPC en Python, lo llamaremos desde pedidos y trataremos los errores que cruzan la red; después veremos el mismo servicio en Java RMI, para entender qué cambia cuando lo remoto son objetos. Terminaremos comparando RPC, RMI y REST. gRPC, el RPC moderno que usará Kilómetro Cero, queda para la lección siguiente: aquí sentamos los conceptos que gRPC da por sabidos.

Contenido

  1. La idea: llamar a un procedimiento como si estuviera aquí
  2. Anatomía de una llamada remota: stubs, marshalling e IDL
  3. Semánticas de invocación: qué pasa cuando algo falla
  4. Transparencia y sus límites
  5. RPC clásico en Python: inventario con XML-RPC
  6. Errores que cruzan la red
  7. RMI: objetos remotos en Java
  8. RPC frente a RMI frente a REST
  9. Errores comunes y consejos
  10. Ejercicios
  11. Conclusión

  1. La idea: llamar a un procedimiento como si estuviera aquí

En el monolito de Kilómetro Cero, confirmar un pedido es una llamada de función Python: inventario.reservar_stock(lineas) (lección 01-06). El módulo pedidos no sabe ni le importa cómo está implementada. RPC propone conservar exactamente esa experiencia cuando inventario pasa a ser otro proceso en otra máquina: el programador escribe la misma llamada y una capa intermedia (el middleware de la lección 01-01) se encarga de convertir los argumentos en bytes, enviarlos, esperar, y convertir la respuesta en un valor.

La propuesta original es de Birrell y Nelson (1984) y ha tenido descendientes en cada década: Sun RPC (la base de NFS), DCE RPC (la base de las llamadas remotas de Windows), CORBA (objetos remotos multilenguaje en los 90), Java RMI (1997), XML-RPC y SOAP (RPC sobre HTTP con XML, 1998-2000), y finalmente Thrift y gRPC (RPC binario con esquemas, 2007-2015). Cambian los formatos y los transportes; la estructura interna es siempre la misma, y es lo que vamos a diseccionar.

  1. Anatomía de una llamada remota: stubs, marshalling e IDL

Una llamada RPC pasa por diez pasos, cinco en cada extremo:

sequenceDiagram
    participant P as pedidos (código de negocio)
    participant SC as Stub cliente
    participant RED as Red (TCP/HTTP)
    participant SK as Skeleton servidor
    participant I as inventario (implementación)
    P->>SC: reservar_stock("queso-curado", 2)
    SC->>SC: marshalling: args → bytes
    SC->>RED: enviar petición
    RED->>SK: recibir petición
    SK->>SK: unmarshalling: bytes → args
    SK->>I: reservar_stock("queso-curado", 2)
    I-->>SK: {"restante": 3}
    SK->>SK: marshalling del resultado
    SK-->>RED: enviar respuesta
    RED-->>SC: recibir respuesta
    SC->>SC: unmarshalling
    SC-->>P: {"restante": 3}

Cada pieza tiene un nombre que conviene fijar, porque los reencontraremos en gRPC:

  • Stub cliente (o proxy): un objeto local que tiene la misma firma que el procedimiento remoto. pedidos lo llama como si fuera la implementación real. Su trabajo es empaquetar la llamada y enviarla.
  • Marshalling (empaquetado) y unmarshalling: conversión de argumentos y resultados entre la representación en memoria del lenguaje y una secuencia de bytes que pueda viajar por la red. Incluye resolver diferencias de representación entre máquinas (orden de bytes de los enteros, codificación de cadenas) y decidir qué hacer con punteros o referencias, que no tienen sentido en otra máquina. El término serialización es prácticamente sinónimo, y los formatos concretos (JSON, XML, Protocol Buffers) son el tema de la lección 02-03.
  • Skeleton servidor (o dispatcher): recibe los bytes, los desempaqueta, identifica qué procedimiento se pide, lo invoca y empaqueta la respuesta.
  • Transporte: el protocolo de la lección anterior (TCP crudo, HTTP...). Al RPC no le importa cuál sea, pero sus propiedades (timeouts, orden, fiabilidad) sí condicionan las garantías.
  • IDL (Interface Definition Language): un lenguaje neutro para describir el contrato, es decir, qué procedimientos existen, con qué parámetros y tipos, y qué devuelven. A partir del IDL, un generador produce automáticamente los stubs y skeletons en cada lenguaje. Sun RPC tenía .x, CORBA tenía su propio IDL, gRPC usa .proto. XML-RPC y Java RMI no tienen IDL separado: el contrato es la propia firma de las funciones Python o la interfaz Java.

El IDL es la pieza conceptualmente más importante. Sin él, el contrato entre pedidos e inventario existe solo en la cabeza de los desarrolladores y en el código de cada lado, y se rompe en silencio cuando uno cambia. Con él, el contrato es un fichero versionado que ambos equipos comparten y del que se genera código: el enfoque contract-first que la lección 02-03 desarrolla.

  1. Semánticas de invocación: qué pasa cuando algo falla

Una llamada local o se ejecuta o lanza una excepción; no hay más opciones. Una llamada remota tiene un tercer resultado, que ya nos encontramos en la lección 01-04: no se sabe. Hay tres puntos donde un mensaje puede perderse, y son indistinguibles para el cliente:

flowchart LR
    A[1. Se pierde la petición] --> X[El cliente solo ve: no llega respuesta]
    B[2. inventario cae después de ejecutar] --> X
    C[3. Se pierde la respuesta] --> X

En el caso 1 la reserva no se hizo; en los casos 2 y 3 sí se hizo. El cliente ve lo mismo: silencio hasta el timeout. Lo que haga el stub ante ese silencio define la semántica de invocación:

Semántica Qué hace el stub Resultado posible en reservar_stock Cuándo es aceptable
Maybe (quizá) Envía una vez, no reintenta, no espera confirmación Ejecutado 0 o 1 veces; el cliente no sabe cuál Telemetría, notificaciones sin importancia
At-least-once (al menos una vez) Reintenta hasta obtener respuesta Ejecutado 1 o más veces: el stock puede descontarse dos veces Operaciones idempotentes (consultar stock, fijar un valor absoluto)
At-most-once (como mucho una vez) Reintenta, pero el servidor filtra duplicados por identificador de petición Ejecutado 0 o 1 veces; si hay respuesta, es de la única ejecución Operaciones con efectos (reservar, cobrar) cuando se tolera que no ocurran
Exactly-once (exactamente una vez) — Ejecutado 1 vez, garantizado No existe de extremo a extremo; ver más abajo

Por qué exactly-once no existe en la práctica. Para garantizar que una operación con efectos se ejecuta exactamente una vez pase lo que pase, haría falta que el servidor ejecutara la operación y registrara "ya la he ejecutado" de forma atómica, y que ese registro sobreviviera a su propia caída, y que el cliente pudiera saber siempre en qué estado quedó. Se puede llegar muy cerca (registro durable de peticiones procesadas, identificadores únicos, reintentos), pero siempre hay una ventana (el servidor cae entre ejecutar y registrar; el disco falla) en la que la garantía se convierte en "al menos una vez" o "como mucho una vez". Lo que sí se puede conseguir es que la operación sea efectivamente una vez: que ejecutarla dos veces tenga el mismo resultado que ejecutarla una (idempotencia). Ese es el enfoque práctico, y la lección 02-05 lo implementa con una tabla de peticiones procesadas en inventario. Aquí basta con entender que la pregunta "¿se ejecutó mi llamada?" no siempre tiene respuesta, y que el diseño del contrato debe asumirlo.

Un detalle útil: at-most-once suele implementarse con un identificador de petición generado por el cliente y una caché de respuestas en el servidor. Si llega una petición repetida, el servidor no la reejecuta: devuelve la respuesta guardada. Guarda este mecanismo en la memoria; es el mismo que reaparecerá en 02-05 con otro nombre.

  1. Transparencia y sus límites

RPC vende transparencia de acceso (lección 01-01): la llamada remota se escribe igual que la local. Es una promesa útil y a la vez peligrosa. En 1994, Waldo, Wyant, Wollrath y Kendall (los diseñadores de lo que sería Java RMI) publicaron "A Note on Distributed Computing", cuya tesis es que hay cuatro diferencias entre lo local y lo remoto que ningún middleware puede ocultar, y que fingir que no existen produce sistemas frágiles:

  1. Latencia. Una llamada local cuesta nanosegundos; una remota, milisegundos: entre cuatro y seis órdenes de magnitud. Un bucle que llama a consultar_stock para cada uno de los 40 productos de un carrito es inocuo en el monolito y desastroso con RPC (40 viajes de ida y vuelta). Falacia 2 de 01-04. Consecuencia de diseño: interfaces remotas de grano grueso (consultar_stock(lista_de_productos)), no de grano fino.
  2. Acceso a memoria. Un puntero o una referencia a un objeto no significa nada en otra máquina. Los argumentos viajan por valor (copiados); modificar la copia en el servidor no modifica el original. Esto cambia la semántica del código de formas sutiles.
  3. Fallos parciales. Ya lo hemos visto: "no se sabe". Localmente, si la función no devuelve es porque el proceso entero murió. Remotamente, el cliente sigue vivo y tiene que decidir qué hacer sin información. Falacia 1.
  4. Concurrencia. El servidor atiende a muchos clientes a la vez sin que el cliente lo vea; el estado compartido en el servidor necesita protección (el candado del ejercicio 1 de la lección anterior), y el orden de llegada de llamadas de distintos clientes no está definido.

La conclusión de Waldo y compañía, que gRPC y toda la industria han acabado adoptando, es que las interfaces remotas deben diseñarse como remotas: firmas explícitas, tipos que viajen bien, timeouts obligatorios, errores de red distinguibles de los errores de aplicación, y sin fingir que un objeto remoto es un objeto local. Tenlo presente al leer el código de los apartados siguientes: XML-RPC y RMI hacen la llamada parecer local, pero en cada punto veremos dónde asoma la red.

  1. RPC clásico en Python: inventario con XML-RPC

XML-RPC es el RPC más simple que existe: las llamadas se codifican en XML y viajan como cuerpo de un POST HTTP. Python lo incluye en la biblioteca estándar (xmlrpc.server y xmlrpc.client), lo que lo hace perfecto para ver los conceptos sin instalar nada. No lo usaríamos en producción (es lento, verboso y sin esquema), pero cada pieza tiene su equivalente en gRPC.

El servidor

# km0/servicios/inventario/rpc_servidor.py
import threading
from socketserver import ThreadingMixIn
from xmlrpc.client import Fault
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer

# Códigos de error del contrato de inventario. Forman parte de la interfaz:
# el cliente los necesita para distinguir causas sin parsear texto.
ERR_PRODUCTO_DESCONOCIDO = 100
ERR_STOCK_INSUFICIENTE = 101
ERR_CANTIDAD_INVALIDA = 102


class Inventario:
    """Implementación del servicio. Todos sus métodos públicos son remotos."""

    def __init__(self):
        self._stock = {
            "tomate-rosa": 120, "calabacin": 80, "queso-curado": 5,
            "queso-fresco": 30, "vino-crianza": 200,
        }
        self._candado = threading.Lock()

    def consultar_stock(self, producto):
        with self._candado:
            unidades = self._stock.get(producto)
        if unidades is None:
            raise Fault(ERR_PRODUCTO_DESCONOCIDO, f"producto desconocido: {producto}")
        return unidades

    def reservar_stock(self, producto, cantidad):
        if not isinstance(cantidad, int) or cantidad <= 0:
            raise Fault(ERR_CANTIDAD_INVALIDA, "la cantidad debe ser un entero positivo")
        with self._candado:
            disponible = self._stock.get(producto)
            if disponible is None:
                raise Fault(ERR_PRODUCTO_DESCONOCIDO, f"producto desconocido: {producto}")
            if disponible < cantidad:
                raise Fault(ERR_STOCK_INSUFICIENTE,
                            f"solo quedan {disponible} unidades de {producto}")
            self._stock[producto] = disponible - cantidad
            restante = self._stock[producto]
        print(f"[inventario] reservadas {cantidad} de {producto}, quedan {restante}")
        return {"producto": producto, "reservado": cantidad, "restante": restante}


class ServidorConHilos(ThreadingMixIn, SimpleXMLRPCServer):
    """SimpleXMLRPCServer atiende en serie; con ThreadingMixIn, un hilo por petición."""
    daemon_threads = True


class Manejador(SimpleXMLRPCRequestHandler):
    rpc_paths = ("/rpc",)     # solo acepta POST a esta ruta


if __name__ == "__main__":
    servidor = ServidorConHilos(("0.0.0.0", 8001), requestHandler=Manejador,
                                allow_none=True, logRequests=False)
    servidor.register_introspection_functions()   # system.listMethods, etc.
    servidor.register_instance(Inventario())       # expone sus métodos públicos
    print("[inventario] XML-RPC escuchando en :8001/rpc")
    servidor.serve_forever()

Puntos importantes:

  • register_instance hace de skeleton: recibe el XML, busca un método con ese nombre en la instancia, lo invoca con los argumentos desempaquetados y empaqueta el resultado. No hay IDL: el contrato es la lista de métodos públicos de Inventario. Los métodos que empiezan por _ no se exponen (por eso _stock y _candado llevan guion bajo, y no solo por convención).
  • Fault es la excepción que forma parte del protocolo: XML-RPC define cómo se codifica en el XML de respuesta (un faultCode entero y un faultString). Definimos códigos propios porque el cliente necesita saber por qué ha fallado sin analizar el texto. Si el método lanzara una excepción Python cualquiera (ValueError), el servidor la convertiría en un Fault genérico con código 1 y un texto como <class 'ValueError'>:...: útil para depurar, inútil para programar contra él.
  • ThreadingMixIn es necesario por la misma razón que los hilos de la lección anterior: sin él, una petición lenta bloquea a todas las demás. Y con él, _candado es imprescindible.
  • Los tipos que XML-RPC sabe transportar son pocos: enteros de 32 bits, doubles, cadenas, booleanos, fechas, binarios, listas y diccionarios con claves de texto. Un Decimal para el precio no viaja; un entero de 64 bits, tampoco (en la implementación estándar). Estas limitaciones del marshalling son una de las razones para preferir formatos con esquema (02-03).

El cliente en pedidos

# km0/servicios/pedidos/rpc_cliente.py
import socket
import xmlrpc.client


class TransporteConTimeout(xmlrpc.client.Transport):
    """El Transport por defecto no tiene timeout: la falacia 1 en estado puro."""

    def __init__(self, timeout):
        super().__init__()
        self._timeout = timeout

    def make_connection(self, host):
        conexion = super().make_connection(host)   # http.client.HTTPConnection
        conexion.timeout = self._timeout
        return conexion


# El stub: un objeto local con la misma "forma" que el servicio remoto.
inventario = xmlrpc.client.ServerProxy("http://localhost:8001/rpc",
                                       transport=TransporteConTimeout(2.0),
                                       allow_none=True)

if __name__ == "__main__":
    print("Métodos remotos:", inventario.system.listMethods())
    print("Stock de queso curado:", inventario.consultar_stock("queso-curado"))
    print("Reserva de Ana:", inventario.reservar_stock("queso-curado", 2))
    print("Reserva de Marc:", inventario.reservar_stock("queso-curado", 4))

Salida:

Métodos remotos: ['consultar_stock', 'reservar_stock', 'system.listMethods', ...]
Stock de queso curado: 5
Reserva de Ana: {'producto': 'queso-curado', 'reservado': 2, 'restante': 3}
Traceback (most recent call last):
  ...
xmlrpc.client.Fault: <Fault 101: 'solo quedan 3 unidades de queso-curado'>

ServerProxy es el stub: inventario.reservar_stock(...) no existe como método Python; ServerProxy intercepta el acceso al atributo, construye un XML con el nombre del método y los argumentos, hace el POST, y desempaqueta la respuesta. Para el desarrollador de pedidos, es una llamada. Para ver lo que viaja, este es el cuerpo de la petición de Ana:

<?xml version='1.0'?>
<methodCall>
  <methodName>reservar_stock</methodName>
  <params>
    <param><value><string>queso-curado</string></value></param>
    <param><value><int>2</int></value></param>
  </params>
</methodCall>

Unos 230 bytes para transmitir dos argumentos: el marshalling en XML es cómodo de leer y carísimo de transmitir y de parsear. Y la respuesta de error de Marc:

<methodResponse>
  <fault>
    <value><struct>
      <member><name>faultCode</name><value><int>101</int></value></member>
      <member><name>faultString</name><value><string>solo quedan 3 unidades de queso-curado</string></value></member>
    </struct></value>
  </fault>
</methodResponse>

  1. Errores que cruzan la red

El ejemplo anterior termina con un traceback porque no capturamos el Fault. Un cliente RPC serio debe distinguir cuatro familias de error, porque cada una exige una reacción distinta:

Familia Ejemplo Excepción en xmlrpc.client ¿Se ejecutó la operación? Reacción razonable
Error de aplicación Stock insuficiente, producto desconocido Fault (con nuestro faultCode) Sí, y decidió fallar Tratar como regla de negocio: informar al cliente, no reintentar
No se pudo conectar inventario caído, DNS incorrecto ConnectionRefusedError, socket.gaierror No Reintentar tras una espera, o degradar (02-05 y 07-04)
Timeout Red lenta, servidor saturado socket.timeout / TimeoutError No se sabe Solo reintentar si la operación es idempotente (02-05)
Error de protocolo Ruta incorrecta, proxy que devuelve 502, respuesta no XML ProtocolError, xmlrpc.client.ResponseError Normalmente no, pero un 502 de un proxy no lo garantiza Registrar y alertar: suele ser un error de despliegue o configuración

El cliente de pedidos con manejo completo:

# km0/servicios/pedidos/confirmar_pedido.py
import socket
import xmlrpc.client
from rpc_cliente import inventario, TransporteConTimeout  # noqa: F401

ERR_STOCK_INSUFICIENTE = 101


def reservar_para_pedido(producto, cantidad):
    """Devuelve (ok, mensaje). Nunca lanza: convierte cada familia de error
    en una decisión de negocio explícita."""
    try:
        r = inventario.reservar_stock(producto, cantidad)
        return True, f"reservadas {r['reservado']} de {producto}, quedan {r['restante']}"
    except xmlrpc.client.Fault as f:
        if f.faultCode == ERR_STOCK_INSUFICIENTE:
            return False, f"sin stock suficiente: {f.faultString}"
        return False, f"inventario rechazó la petición ({f.faultCode}): {f.faultString}"
    except (ConnectionRefusedError, socket.gaierror) as e:
        # Seguro que no se ejecutó: se puede reintentar sin riesgo.
        return False, f"inventario no disponible ({e}); pedido en espera"
    except (socket.timeout, TimeoutError):
        # AMBIGUO: pudo ejecutarse. Reintentar aquí duplicaría reservas (01-04).
        return False, "inventario no respondió a tiempo; estado de la reserva desconocido"
    except xmlrpc.client.ProtocolError as e:
        return False, f"error de protocolo {e.errcode} en {e.url}: {e.errmsg}"


if __name__ == "__main__":
    for cliente, producto, cantidad in [("Ana", "queso-curado", 2),
                                        ("Marc", "queso-curado", 4),
                                        ("Lucía", "vino-crianza", 6)]:
        ok, msg = reservar_para_pedido(producto, cantidad)
        print(f"{cliente}: {'OK' if ok else 'NO'} - {msg}")

Dos observaciones. La primera: la rama del timeout es la única que no puede resolverse en esta lección. Con lo que sabemos ahora, lo más honesto es dejar el pedido en un estado "reserva pendiente de confirmar" y no reintentar; la solución completa (que la petición lleve un identificador y que inventario recuerde cuáles ha procesado) es la idempotencia de 02-05. La segunda: la excepción Fault viaja por la red; las demás nacen en el cliente. Esta distinción se mantiene en todos los sistemas RPC: gRPC la formaliza con códigos de estado (02-03), y REST con códigos HTTP 4xx (aplicación) frente a errores de conexión y 5xx (infraestructura).

  1. RMI: objetos remotos en Java

Java RMI lleva la idea un paso más allá: lo remoto no es un procedimiento sino un objeto, con estado y con identidad. El cliente obtiene una referencia remota a un objeto que vive en otra JVM y le invoca métodos. Aunque Kilómetro Cero está en Python, merece la pena ver RMI porque muestra con toda claridad la maquinaria (interfaz remota, stub, registro, serialización) y porque sigue vivo en muchísimos sistemas Java corporativos con los que un arquitecto se encontrará.

Las piezas

  1. Interfaz remota: extiende java.rmi.Remote; cada método declara throws RemoteException. Es el contrato (hace el papel del IDL).
  2. Implementación: extiende UnicastRemoteObject, que al construirse exporta el objeto: lo hace accesible por la red en un puerto y crea su skeleton.
  3. Registro (rmiregistry): un servicio de nombres donde el servidor publica el objeto con un nombre y el cliente lo busca. Es una forma primitiva de descubrimiento de servicios.
  4. Stub: desde Java 5 se genera dinámicamente (con java.lang.reflect.Proxy); el cliente recibe uno al hacer lookup y lo usa como si fuera el objeto.
  5. Serialización: los argumentos y resultados deben implementar java.io.Serializable (viajan por valor) o ser objetos remotos (viajan por referencia: se envía el stub).

El contrato

// km0/servicios/inventario-java/Inventario.java
import java.rmi.Remote;
import java.rmi.RemoteException;

public interface Inventario extends Remote {
    int consultarStock(String producto) throws RemoteException, ProductoDesconocidoException;
    Reserva reservarStock(String producto, int cantidad)
            throws RemoteException, ProductoDesconocidoException, StockInsuficienteException;
}

RemoteException en cada firma es RMI negándose a ocultar la falacia 1: toda llamada remota puede fallar por la red, y el compilador obliga a tratarlo. Las otras dos excepciones son de aplicación (el equivalente a nuestros Fault con código).

// km0/servicios/inventario-java/Reserva.java
import java.io.Serializable;

public class Reserva implements Serializable {
    private static final long serialVersionUID = 1L;   // versión del formato serializado
    public final String producto;
    public final int reservado;
    public final int restante;

    public Reserva(String producto, int reservado, int restante) {
        this.producto = producto; this.reservado = reservado; this.restante = restante;
    }
}

// km0/servicios/inventario-java/StockInsuficienteException.java
public class StockInsuficienteException extends Exception {
    public StockInsuficienteException(String mensaje) { super(mensaje); }
}

// km0/servicios/inventario-java/ProductoDesconocidoException.java
public class ProductoDesconocidoException extends Exception {
    public ProductoDesconocidoException(String mensaje) { super(mensaje); }
}

Reserva viaja por valor: el servidor construye una, RMI la serializa a bytes, el cliente recibe una copia. serialVersionUID es la versión del formato: si el servidor añade un campo y no lo actualiza (o lo actualiza sin que el cliente se recompile), la deserialización falla. Es el problema de la evolución de esquemas en su forma más cruda, que 02-03 resuelve con reglas explícitas. Las excepciones son Serializable a través de Throwable, así que cruzan la red sin más.

La implementación y el servidor

// km0/servicios/inventario-java/InventarioImpl.java
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.HashMap;
import java.util.Map;

public class InventarioImpl extends UnicastRemoteObject implements Inventario {
    private final Map<String, Integer> stock = new HashMap<>();

    public InventarioImpl() throws RemoteException {
        super();   // exporta el objeto: a partir de aquí es accesible por red
        stock.put("tomate-rosa", 120); stock.put("calabacin", 80);
        stock.put("queso-curado", 5);  stock.put("queso-fresco", 30);
        stock.put("vino-crianza", 200);
    }

    // synchronized: RMI atiende cada llamada en un hilo; el estado es compartido
    public synchronized int consultarStock(String producto) throws ProductoDesconocidoException {
        Integer u = stock.get(producto);
        if (u == null) throw new ProductoDesconocidoException("producto desconocido: " + producto);
        return u;
    }

    public synchronized Reserva reservarStock(String producto, int cantidad)
            throws ProductoDesconocidoException, StockInsuficienteException {
        int disponible = consultarStock(producto);
        if (disponible < cantidad)
            throw new StockInsuficienteException("solo quedan " + disponible + " de " + producto);
        stock.put(producto, disponible - cantidad);
        System.out.println("[inventario] reservadas " + cantidad + " de " + producto);
        return new Reserva(producto, cantidad, disponible - cantidad);
    }
}
// km0/servicios/inventario-java/ServidorInventario.java
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;

public class ServidorInventario {
    public static void main(String[] args) throws Exception {
        LocateRegistry.createRegistry(1099);             // arranca el registro en este proceso
        Inventario inventario = new InventarioImpl();     // exportado al construirse
        Naming.rebind("rmi://localhost:1099/inventario", inventario);
        System.out.println("[inventario] RMI publicado como 'inventario'");
        // El proceso no termina: el objeto exportado mantiene la JVM viva
    }
}

El cliente en pedidos

// km0/servicios/pedidos-java/ClientePedidos.java
import java.rmi.Naming;
import java.rmi.RemoteException;

public class ClientePedidos {
    public static void main(String[] args) {
        try {
            // lookup devuelve el STUB: un proxy que implementa Inventario
            Inventario inventario = (Inventario) Naming.lookup("rmi://localhost:1099/inventario");
            System.out.println("Stock: " + inventario.consultarStock("queso-curado"));
            Reserva ana = inventario.reservarStock("queso-curado", 2);
            System.out.println("Ana: quedan " + ana.restante);
            Reserva marc = inventario.reservarStock("queso-curado", 4);
            System.out.println("Marc: quedan " + marc.restante);
        } catch (StockInsuficienteException e) {
            System.out.println("Sin stock: " + e.getMessage());          // error de aplicación
        } catch (ProductoDesconocidoException e) {
            System.out.println("Producto desconocido: " + e.getMessage());
        } catch (RemoteException e) {
            System.out.println("Fallo de red o del servidor: " + e);      // ¿se ejecutó? no se sabe
        } catch (java.net.MalformedURLException | java.rmi.NotBoundException e) {
            System.out.println("Error de configuración: " + e);          // nombre o URL incorrectos
        }
    }
}

Para ejecutarlo (con la interfaz y las clases serializables compartidas entre ambos programas):

javac *.java
java ServidorInventario &
# Timeout de respuesta de 2 s: RMI tampoco lo tiene por defecto
java -Dsun.rmi.transport.tcp.responseTimeout=2000 ClientePedidos

Salida esperada:

Stock: 5
Ana: quedan 3
Sin stock: solo quedan 3 de queso-curado

Lo que RMI añade sobre RPC, y su precio:

  • Referencias remotas. Si un método devolviera otro objeto Remote (por ejemplo, Reserva fuera remota para poder cancelar() después), el cliente recibiría un stub a ese objeto, que seguiría viviendo en el servidor. Esto permite diseños orientados a objetos naturales... y crea estado en el servidor ligado a clientes, que hay que limpiar cuando desaparecen (RMI tiene un recolector de basura distribuido basado en arrendamientos, leases). Es exactamente el tipo de acoplamiento que los servicios modernos evitan: gRPC y REST son deliberadamente sin estado por llamada.
  • Acoplamiento a Java. Ambos extremos deben ser JVM, compartir las clases y sus serialVersionUID. Sin IDL neutro, no hay cliente Python posible.
  • Serialización nativa de Java, históricamente fuente de vulnerabilidades graves (deserialización de objetos no confiables). Una razón más para los formatos con esquema de 02-03.

  1. RPC frente a RMI frente a REST

REST no es RPC: es un estilo de arquitectura sobre HTTP en el que se manipulan recursos identificados por URL mediante los verbos estándar (GET, POST, PUT, DELETE), con la semántica de cada verbo bien definida (por ejemplo, GET y PUT son idempotentes por contrato). Pero compite con RPC por el mismo puesto (comunicación síncrona entre servicios), así que conviene compararlos:

Criterio RPC (XML-RPC, gRPC) RMI REST
Unidad de diseño Procedimiento / función Objeto con métodos y estado Recurso con representación
Contrato IDL (gRPC) o firma de función (XML-RPC) Interfaz Java URL + verbos HTTP + esquema del cuerpo (OpenAPI, opcional)
Ejemplo reservar_stock reservar_stock("queso-curado", 2) inventario.reservarStock("queso-curado", 2) POST /productos/queso-curado/reservas {"cantidad": 2}
Lenguajes Multilenguaje (gRPC), Python (XML-RPC) Solo JVM Cualquiera con HTTP
Errores Códigos propios / excepciones remotas Excepciones Java serializadas Códigos de estado HTTP + cuerpo
Estado en el servidor entre llamadas No (por diseño) Posible (referencias remotas) No (uno de sus principios)
Idempotencia Definida por convención por operación Definida por convención Definida por el verbo (GET, PUT, DELETE sí; POST no)
Rendimiento Alto (gRPC binario, HTTP/2) / bajo (XML) Medio (serialización Java) Medio (JSON sobre HTTP/1.1, habitualmente)
Caché intermedia (proxies, CDN) No No Sí, nativa de HTTP para GET
Uso típico Servicio a servicio, interno Sistemas Java corporativos heredados APIs públicas, navegadores, terceros
En Kilómetro Cero pedidos → inventario (gRPC, 02-03) No aplica (Python) API pública para apps y productores (06-05)

La regla práctica que adoptará Kilómetro Cero: REST hacia fuera, RPC hacia dentro. Las apps de Ana, Marc y Lucía y los productores hablan REST con una puerta de enlace (lección 06-05), porque es universal, cacheable y no exige generar clientes. Entre servicios, donde controlamos ambos extremos y el volumen es alto, RPC con esquema es más eficiente y más seguro frente a errores de contrato.

Errores Comunes y Consejos

  • Diseñar la interfaz remota como si fuera local. Cuarenta llamadas de un elemento cada una en lugar de una llamada con cuarenta elementos. Cada llamada remota cuesta un viaje de ida y vuelta; agrupa.
  • Tratar el timeout como "no se ejecutó". Es la forma más frecuente de duplicar reservas y cobros. Un timeout significa "no sé"; reintentar solo es seguro si la operación es idempotente (02-05).
  • Usar la excepción genérica para todo. Si el cliente no puede distinguir "sin stock" de "servidor caído", no puede reaccionar bien a ninguno. Códigos de error explícitos en el contrato, siempre.
  • Olvidar el timeout en el cliente. Ni ServerProxy ni RMI lo ponen por defecto. Ya hemos visto en 01-04 y 02-01 lo que ocurre.
  • Exponer objetos con estado por referencia (RMI). Cada referencia remota es estado en el servidor atado a un cliente que puede desaparecer. Prefiere llamadas sin estado con identificadores explícitos.
  • Cambiar la firma de un método remoto sin versionar. Con XML-RPC, el cliente viejo fallará en tiempo de ejecución con un error críptico; con RMI, con una excepción de deserialización. Es el problema que resuelven el IDL y las reglas de evolución (02-03).
  • Consejo: cuando escribas un cliente RPC, escribe antes la tabla de las cuatro familias de error (aplicación, conexión, timeout, protocolo) y qué hará tu código en cada una. Si una fila queda vacía, el diseño no está terminado.
  • Consejo: genera identificadores de petición en el cliente desde el primer día, aunque todavía no los uses. Cuando llegue 02-05 y quieras idempotencia, ya estarán en los logs y en los contratos.

Ejercicios

Ejercicio 1: Semánticas de invocación

Un cliente de pedidos llama a reservar_stock("queso-curado", 2) con un stub que reintenta hasta 3 veces ante timeout. En cada uno de estos escenarios, indica cuántas veces se ejecuta la reserva en inventario, qué ve el cliente, y qué semántica se está aplicando: (a) la primera petición se pierde en la red y la segunda llega y responde; (b) la primera llega y se ejecuta, pero la respuesta se pierde; la segunda llega y responde; (c) las tres peticiones llegan y se ejecutan, y las tres respuestas se pierden. Después, describe qué debería añadir el servidor para que el escenario (b) no duplicara la reserva.

Ejercicio 2: Un método de grano grueso

Añade a Inventario (versión XML-RPC) un método reservar_lineas(lineas) que reciba una lista de diccionarios {"producto": ..., "cantidad": ...} y reserve todas o ninguna: si alguna línea no tiene stock, no se descuenta nada y se devuelve un Fault con código 101 indicando qué producto falló. Explica por qué este método es preferible a llamar a reservar_stock en bucle desde pedidos, en términos de latencia y de fallos parciales.

Ejercicio 3: Clasificar errores

Un desarrollador observa estos cinco mensajes en los logs de pedidos durante la "Semana del Queso Artesano". Para cada uno, indica la familia de error (aplicación, conexión, timeout, protocolo), si la operación se ejecutó en inventario y si es correcto reintentar automáticamente:

  1. xmlrpc.client.Fault: <Fault 101: 'solo quedan 1 unidades de queso-curado'>
  2. ConnectionRefusedError: [Errno 111] Connection refused
  3. socket.timeout: timed out
  4. xmlrpc.client.ProtocolError: <ProtocolError for localhost:8001/rpc: 502 Bad Gateway>
  5. xmlrpc.client.Fault: <Fault 1: "<class 'TypeError'>:unsupported operand type(s)">

Soluciones

Solución 1:

(a) La reserva se ejecuta una vez (solo la segunda petición llegó). El cliente ve una respuesta correcta tras un timeout. Semántica efectiva: al menos una vez, que en este caso coincide con exactamente una.

(b) La reserva se ejecuta dos veces: el stock baja 4 unidades aunque Ana quería 2. El cliente ve una respuesta correcta (de la segunda ejecución) y no tiene forma de saber que hubo una primera. Semántica: at-least-once, con su consecuencia típica de duplicación.

(c) Se ejecuta tres veces (el stock baja 6) y el cliente ve un fallo definitivo tras tres timeouts: cree que no se ha reservado nada. Es el peor caso: ejecución múltiple más información falsa en el cliente. Es lo que la simulación de 01-04 mostraba con la semilla 16.

Para que (b) no duplicara, el servidor necesitaría semántica at-most-once: el cliente incluye un identificador único de petición (por ejemplo, un UUID generado antes del primer intento y reutilizado en los reintentos), y el servidor guarda, por identificador, la respuesta de cada petición ya ejecutada. Al llegar el reintento con el mismo identificador, no reejecuta: devuelve la respuesta guardada. La reserva ocurre una vez y el cliente recibe su resultado. La implementación con persistencia en PostgreSQL es el tema de 02-05.

Solución 2:

def reservar_lineas(self, lineas):
    with self._candado:
        # Fase 1: comprobar todo sin tocar nada
        for linea in lineas:
            producto, cantidad = linea["producto"], linea["cantidad"]
            if not isinstance(cantidad, int) or cantidad <= 0:
                raise Fault(ERR_CANTIDAD_INVALIDA, f"cantidad inválida para {producto}")
            disponible = self._stock.get(producto)
            if disponible is None:
                raise Fault(ERR_PRODUCTO_DESCONOCIDO, f"producto desconocido: {producto}")
            if disponible < cantidad:
                raise Fault(ERR_STOCK_INSUFICIENTE,
                            f"{producto}: solo quedan {disponible}, se pedían {cantidad}")
        # Fase 2: aplicar todo (nada puede fallar ya, y el candado sigue tomado)
        resultado = []
        for linea in lineas:
            self._stock[linea["producto"]] -= linea["cantidad"]
            resultado.append({"producto": linea["producto"],
                              "restante": self._stock[linea["producto"]]})
    return resultado

Ventajas frente al bucle en el cliente: (1) Latencia: un carrito con 6 líneas cuesta un viaje de ida y vuelta en lugar de seis (con RTT de 2 ms entre contenedores parece poco; en campaña, con 1.200 pedidos por segundo, son 6.000 llamadas frente a 1.200). (2) Fallos parciales: con el bucle, si la cuarta línea falla por stock o por timeout, las tres primeras ya están reservadas y pedidos tiene que deshacerlas con más llamadas remotas, que también pueden fallar. Con la operación atómica en el servidor, o se reserva todo o nada, y el candado garantiza que ningún otro pedido se cuela entre la comprobación y la aplicación. La atomicidad es fácil aquí porque todo el stock vive en un proceso; cuando esté repartido entre inv-bcn e inv-vlc, esta misma operación se convierte en una transacción distribuida (lección 03-05).

Solución 3:

  1. Aplicación (código 101). Se ejecutó la comprobación y inventario decidió rechazar. No reintentar: el resultado será el mismo; informar al cliente de que no queda stock.
  2. Conexión. No se ejecutó (la petición nunca salió). Reintentar es seguro, pero conviene esperar (el servicio está caído o reiniciándose) y limitar los reintentos; ver 07-04 para la estrategia completa.
  3. Timeout. No se sabe. No reintentar automáticamente una reserva sin idempotencia (02-05); dejar el pedido pendiente y comprobar después.
  4. Protocolo (un proxy o balanceador respondió 502). Casi seguro que la petición no llegó a inventario, pero un 502 puede producirse si el backend cerró la conexión a mitad de respuesta, así que es tan ambiguo como un timeout. No reintentar sin idempotencia; alertar: suele indicar un despliegue o una configuración incorrecta.
  5. Aplicación, pero por un bug: el código 1 genérico y el TypeError indican que el servidor lanzó una excepción no controlada (probablemente alguien pasó "2" como cadena en lugar de 2). Se ejecutó parcialmente hasta el error; con el candado y sin efectos previos, el stock no cambió. No reintentar (fallaría igual); corregir el cliente y, en el servidor, validar tipos antes de tocar el estado.

Conclusión

Hemos abierto la caja de una llamada remota. Un stub en el cliente empaqueta (marshalling) los argumentos y los envía; un skeleton en el servidor los desempaqueta, invoca la implementación y devuelve el resultado; un IDL, cuando existe, fija el contrato entre ambos y permite generar los stubs. La promesa de RPC es que todo esto sea invisible, y hemos visto por qué no puede serlo del todo: la latencia obliga a diseñar interfaces de grano grueso, los argumentos viajan por valor, y los fallos parciales introducen el resultado "no se sabe", que las semánticas de invocación (maybe, at-least-once, at-most-once) gestionan de formas distintas; exactly-once no existe de extremo a extremo, y lo que se persigue en su lugar es la idempotencia. Con XML-RPC hemos implementado inventario.consultar_stock y reservar_stock, los hemos llamado desde pedidos con timeout y hemos clasificado los errores en cuatro familias con reacciones distintas; con Java RMI hemos visto la misma maquinaria aplicada a objetos remotos, con sus referencias, su registro y su acoplamiento a la JVM. REST, RPC y RMI ocupan lugares distintos: REST hacia fuera, RPC hacia dentro.

Lo que XML-RPC y RMI no resuelven bien es evidente en el código: XML de 230 bytes para dos argumentos, tipos limitados, contratos implícitos que se rompen en silencio al evolucionar, y ausencia de streaming. La siguiente lección, gRPC y Serialización de Datos, aborda exactamente eso: un IDL explícito (.proto), un formato binario compacto con reglas de evolución, y un RPC sobre HTTP/2 con deadlines, códigos de estado y cuatro tipos de llamada. Será la versión definitiva de la interfaz pedidos → inventario de Kilómetro Cero.

Curso de Arquitecturas Distribuidas

Módulo 1: Introducción a los Sistemas Distribuidos

Módulo 2: Comunicación en Sistemas Distribuidos

Módulo 3: Consistencia y Replicación

Módulo 4: Almacenamiento Distribuido

Módulo 5: Computación Distribuida

Módulo 6: Seguridad en Sistemas Distribuidos

Módulo 7: Monitoreo y Mantenimiento

Módulo 8: Casos de Estudio y Aplicaciones

© Copyright 2026. Todos los derechos reservados