Diseño de API: contrato, versiones, solicitudes repetidas
En resumen: Una buena API es un contrato: recursos, métodos, estados y campos en los que se apoya el cliente; una clave de idempotencia hace segura una solicitud repetida, y las versiones añaden sin romper.
Problema
Pagado dos veces
Una clienta paga el pedido 1001 desde la aplicación móvil. La aplicación envía una solicitud a la API de la tienda, el servidor cobra 120 €, pero la conexión se corta antes de que llegue la respuesta. La aplicación no sabe si pagó. Así que hace lo que haría cualquier cliente sensato: lo intenta de nuevo.
Así cobra el servidor:
class PaymentApi implements Api { final Payments payments = new Payments();
public Response handle(Request req) { return payments.charge(req.order(), req.amount()); }}Y la aplicación lo intenta hasta tres veces:
static Response pay(Api api, int order, int amount) { String path = "/orders/" + order + "/payments"; var req = new Request("POST", path, null, amount); for (int attempt = 1; attempt <= 3; attempt++) { Response res = network.send(api, req); if (res != null) { System.out.println("intento " + attempt + ": " + res); return res; } System.out.println("intento " + attempt + ": la respuesta se pierde"); } return null;}La red es un guion grabado: la respuesta a la primera llamada se pierde. El servidor hizo el trabajo; solo se pierde la respuesta.
var api = new PaymentApi();pay(api, 1001, 120);System.out.println(api.payments.report());intento 1: la respuesta se pierdeintento 2: 201 pagado 1001 (pay-2)pagos: 2, total 240 €La clienta ve «pagado» una vez, y 240 € han salido de su tarjeta. El servidor no tiene forma de saber que la segunda solicitud es el mismo pago: para él es un segundo pago. Una respuesta perdida no es rara: una red móvil en el metro, un ascensor, un timeout del proxy.
La idea
Una API es un contrato
Una API es una promesa a sus clientes: qué hay en cada dirección, qué acepta y qué devuelve. El cliente se escribe contra esa promesa, a menudo por otro equipo u otra empresa, y no puede cambiar a la vez que el servidor. Esa promesa es el contrato de la API.
En una API HTTP el contrato se compone de unas pocas partes:
- un recurso es algo con una dirección:
/orders/1001es un pedido,/orders/1001/paymentsson sus pagos. La dirección nombra una cosa, no una acción:/payments, no/makePayment; - el método dice qué se hace con el recurso:
GETlee,POSTcrea,PUTescribe todo,PATCHcambia una parte,DELETEborra; - el estado dice cómo salió:
200bien,201creado,400solicitud incorrecta,404no existe ese recurso,409conflicto,422la solicitud se entiende pero no se puede llevar a cabo,5xxel error está en el servidor; - los campos de la solicitud y de la respuesta, con sus nombres y tipos.
GET, PUT y DELETE son idempotentes según las reglas de HTTP: la segunda vez dan el mismo resultado. Por eso los clientes y los proxies los repiten sin miedo. POST no lo es: cada POST crea algo nuevo. Un pago es un POST, así que repetirlo cobra otra vez.
Cómo se ven una solicitud y una respuesta HTTP lo viste en la lección Programación en red.
Míralo
Solicitud, respuesta y red en memoria
Aquí no hay servidor HTTP. La API es un objeto con un método handle: recibe una solicitud y devuelve una respuesta. La solicitud lleva un método, una ruta, una clave y un importe, y la respuesta lleva un estado y un texto:
record Request(String method, String path, String key, int amount) { // /orders/1001/payments → 1001 int order() { return Integer.parseInt(path.split("/")[2]); }}
record Response(int status, String body) { public String toString() { return status + " " + body; }}
interface Api { Response handle(Request req);}La red pasa la solicitud a la API y, según el guion, pierde la respuesta:
// un guion grabado: en qué llamadas se pierde la respuestaclass Network { private final Set<Integer> lost; private int calls = 0;
Network(Integer... lost) { this.lost = Set.of(lost); }
Response send(Api api, Request req) { calls++; Response res = api.handle(req); // el servidor hizo el trabajo; solo se pierde la respuesta return lost.contains(calls) ? null : res; }}Los pagos recuerdan los importes:
class Payments { private final List<Integer> amounts = new ArrayList<>();
Response charge(int order, int amount) { amounts.add(amount); int n = amounts.size(); return new Response(201, "pagado " + order + " (pay-" + n + ")"); }
String report() { int sum = amounts.stream().mapToInt(Integer::intValue).sum(); return "pagos: " + amounts.size() + ", total " + sum + " €"; }}La idea
Una clave de idempotencia
El servidor tiene que distinguir «el mismo pago, intentado otra vez» de «un pago nuevo». Solo no puede: las dos solicitudes se ven iguales. Así que se lo dice el cliente. Antes del primer intento el cliente crea una clave de idempotencia única (normalmente un UUID) y la envía en cada intento, en el encabezado Idempotency-Key.
El servidor guarda «clave → respuesta». Si la clave es nueva, el servidor hace el trabajo y recuerda la respuesta. Si la clave ya se vio, el servidor no hace nada y devuelve la respuesta guardada. El cliente recibe lo mismo que habría recibido la primera vez.
Esta es la idea del consumidor idempotente de la lección sobre eventos, vuelta hacia el cliente: allí el consumidor recuerda el id del evento, aquí el servidor recuerda la clave de la solicitud.
Míralo
El pago repetido con clave
El cliente crea la clave una vez, antes del bucle:
static int keys = 0;
static Response payWithKey(Api api, int order, int amount) { // una clave para todos los intentos; un cliente real usa un UUID String key = "k-" + (++keys); String path = "/orders/" + order + "/payments"; var req = new Request("POST", path, key, amount); for (int attempt = 1; attempt <= 3; attempt++) { Response res = network.send(api, req); if (res != null) { System.out.println("intento " + attempt + ": " + res); return res; } System.out.println("intento " + attempt + ": la respuesta se pierde"); } return null;}El servidor comprueba la clave antes de cobrar:
class IdempotentApi implements Api { final Payments payments = new Payments(); // clave → la respuesta que ya se dio final Map<String, Response> done = new HashMap<>();
public Response handle(Request req) { if (req.key() == null) return new Response(400, "sin Idempotency-Key"); Response saved = done.get(req.key()); if (saved != null) return saved; var res = payments.charge(req.order(), req.amount()); done.put(req.key(), res); return res; }}El mismo guion: la primera respuesta se pierde.
var api = new IdempotentApi();payWithKey(api, 1001, 120);System.out.println(api.payments.report());intento 1: la respuesta se pierdeintento 2: 201 pagado 1001 (pay-1)pagos: 1, total 120 €class IdempotentApi implements Api { final Payments payments = new Payments(); // clave → la respuesta que ya se dio final Map<String, Response> done = new HashMap<>();
public Response handle(Request req) { if (req.key() == null) return new Response(400, "sin Idempotency-Key"); Response saved = done.get(req.key()); if (saved != null) return saved; var res = payments.charge(req.order(), req.amount()); done.put(req.key(), res); return res; }}static Response pay(Api api, int order, int amount) { String path = "/orders/" + order + "/payments"; var req = new Request("POST", path, null, amount); for (int attempt = 1; attempt <= 3; attempt++) { Response res = network.send(api, req); if (res != null) { System.out.println("intento " + attempt + ": " + res); return res; } System.out.println("intento " + attempt + ": la respuesta se pierde"); } return null;}class PaymentApi implements Api { final Payments payments = new Payments();
public Response handle(Request req) { return payments.charge(req.order(), req.amount()); }}static int keys = 0;
static Response payWithKey(Api api, int order, int amount) { // una clave para todos los intentos; un cliente real usa un UUID String key = "k-" + (++keys); String path = "/orders/" + order + "/payments"; var req = new Request("POST", path, key, amount); for (int attempt = 1; attempt <= 3; attempt++) { Response res = network.send(api, req); if (res != null) { System.out.println("intento " + attempt + ": " + res); return res; } System.out.println("intento " + attempt + ": la respuesta se pierde"); } return null;}class IdempotentApi implements Api { final Payments payments = new Payments(); // clave → la respuesta que ya se dio final Map<String, Response> done = new HashMap<>();
public Response handle(Request req) { if (req.key() == null) return new Response(400, "sin Idempotency-Key"); Response saved = done.get(req.key()); if (saved != null) return saved; var res = payments.charge(req.order(), req.amount()); done.put(req.key(), res); return res; }}Paso a paso, en palabras
- Escenario 1, sin clave: el teléfono envía POST /orders/1001/payments por 120 €.
- El servidor cobra 120 € (pay-1) y responde 201.
- La conexión se corta: la respuesta se pierde en el camino. El teléfono no sabe si pagó.
- El teléfono lo intenta de nuevo con la misma solicitud.
- El servidor no sabe que es el mismo pago: cobra otros 120 € (pay-2). En total 240 €.
- Escenario 2, con clave: el cliente crea la clave k-1 una vez y la envía en cada intento.
- La clave es nueva: el servidor cobra 120 € y recuerda k-1 → su respuesta.
- La respuesta se pierde otra vez.
- El reintento lleva la misma clave, k-1.
- El servidor encuentra k-1 y devuelve la respuesta guardada, pay-1, sin cobrar otra vez.
- Sin clave: 2 pagos, 240 €. Con clave: 1 pago, 120 €.
El segundo intento recibe pay-1, la misma respuesta que el primero, y el dinero se cobra una vez. Sin clave el servidor rechaza con 400: un pago sin clave no se puede repetir con seguridad, así que esta API no lo acepta. Algunas APIs (Stripe, por ejemplo) aceptan también solicitudes sin clave, pero entonces no prometen nada al repetir.
Pruébalo
Encuentra el error
Un compañero reorganiza el cliente y la clave acaba dentro del bucle. La API es la misma, con clave:
intento 1: la respuesta se pierdeintento 2: 201 pagado 1001 (pay-2)pagos: 2, total 240 €Otra vez dos pagos. ¿En qué línea está el error?
Elige la línea con el error.
La clave se crea en cada intento, así que el segundo intento lleva k-2. Para el servidor es una operación nueva, y cobra otra vez. La clave nombra el pago, no el intento: se crea una vez, antes del bucle, y se conserva hasta que el pago recibe una respuesta. Un cliente real la conserva incluso si la aplicación se reinicia.
La idea
Una versión nueva sin romper a los clientes antiguos
La aplicación móvil del mes pasado sigue en miles de teléfonos y no se actualizará hoy. Cada cambio en la API tiene que funcionar también con ella. ¿Puede el cambio romper a un cliente antiguo? Comprueba el contrato de la respuesta: cada campo antiguo tiene que estar, con el mismo tipo.
// el contrato de la respuesta: nombre y tipo del camporecord Field(String name, String type) {}
static String check(List<Field> old, List<Field> next) { List<String> problems = new ArrayList<>(); for (Field f : old) { Field now = next.stream() .filter(n -> n.name().equals(f.name())) .findFirst().orElse(null); if (now == null) problems.add("falta " + f.name()); else if (!now.type().equals(f.type())) problems.add(f.name() + ": " + f.type() + " → " + now.type()); } if (problems.isEmpty()) return "✓ no rompe"; return "✗ rompe: " + String.join(", ", problems);}var id = new Field("id", "string");var status = new Field("status", "string");var v1 = List.of(id, status, new Field("amount", "number"));var added = new ArrayList<>(v1);added.add(new Field("currency", "string"));var renamed = List.of(id, status, new Field("total", "number"));var retyped = List.of(id, status, new Field("amount", "string"));System.out.println("campo currency añadido: " + check(v1, added));System.out.println("amount pasó a ser total: " + check(v1, renamed));System.out.println("amount pasó a ser texto: " + check(v1, retyped));campo currency añadido: ✓ no rompeamount pasó a ser total: ✗ rompe: falta amountamount pasó a ser texto: ✗ rompe: amount: number → stringLas reglas que salen de aquí:
- añadir, no quitar. Un campo nuevo en la respuesta es seguro: el cliente antiguo no lo lee. Un campo quitado, renombrado o con otro tipo rompe;
- en la solicitud es al revés. Un campo obligatorio nuevo en la solicitud rompe a los clientes antiguos, porque no lo envían. Un campo nuevo tiene que ser opcional, con un valor por defecto;
- que el cliente sea tolerante. Un cliente que falla ante un campo desconocido o ante un valor nuevo en una lista de estados se rompe a sí mismo. El cliente lee solo lo que necesita y se salta el resto;
- un cambio que rompe es una versión nueva.
/v2/ordersjunto a/v1/orders, las dos funcionando hasta que los clientes antiguos se pasen. La versión antigua se anuncia como obsoleta con una fecha y después se apaga.
La idea
Paginación: offset o cursor
Hay miles de pedidos, y la aplicación muestra 20 a la vez. La paginación más simple es el offset: «sáltate los primeros 40, dame 20». El cursor es lo contrario: «dame 20 después del pedido 1007». El cursor es el último id de la página anterior, y el servidor lo devuelve en la respuesta para que el cliente pida la página siguiente.
// los pedidos, los más nuevos primerostatic List<Integer> orders = new ArrayList<>(List.of(1009, 1008, 1007, 1006, 1005, 1004, 1003, 1002, 1001));
static List<Integer> byOffset(int offset, int limit) { return orders.stream().skip(offset).limit(limit).toList();}
// el cursor: el último id de la página anteriorstatic List<Integer> afterCursor(int cursor, int limit) { return orders.stream().filter(id -> id < cursor).limit(limit).toList();}El cliente lee la primera página, y entre las dos páginas llega un pedido nuevo:
var page1 = byOffset(0, 3);System.out.println("pág. 1: " + page1);orders.add(0, 1010);System.out.println("llega un pedido nuevo 1010");System.out.println("offset, pág. 2: " + byOffset(3, 3));int cursor = page1.get(page1.size() - 1);System.out.println("cursor, pág. 2: " + afterCursor(cursor, 3));pág. 1: [1009, 1008, 1007]llega un pedido nuevo 1010offset, pág. 2: [1007, 1006, 1005]cursor, pág. 2: [1006, 1005, 1004]Con offset, el pedido 1007 sale dos veces: el pedido nuevo ha empujado todo hacia abajo. Con cursor, la segunda página empieza justo después de 1007, llegue lo que llegue arriba.
El cursor también es rápido en la base de datos. OFFSET 10000 hace que la base lea y tire 10 000 filas antes de devolver 20. WHERE id < 1007 ORDER BY id DESC LIMIT 20 va directo al sitio por el índice. El precio del cursor: no puedes saltar a la página 7, y el número de páginas no viene gratis.
En una API real el cursor es una cadena opaca (por ejemplo "eyJpZCI6MTAwN30", el {"id":1007} codificado), para que el cliente no se apoye en lo que hay dentro y el servidor pueda cambiarlo.
Pruébalo
Predice la salida
Ahora el pedido 1008 se borra entre las dos páginas.
La última línea de la salida es:
var page1 = byOffset(0, 3);System.out.println("pág. 1: " + page1);orders.remove(Integer.valueOf(1008));System.out.println("pedido 1008 está borrado");System.out.println("offset, pág. 2: " + byOffset(3, 3));Después de borrar, 1007 es el segundo, 1006 el tercero y 1005 el cuarto. Offset 3 se salta 1009, 1007 y 1006, así que 1006 no se muestra en ninguna página. Un borrado mientras se lee se salta una fila, una inserción repite una. El cursor «después de 1007» habría devuelto 1006, 1005, 1004.
La idea
Cuándo estorba
Una clave de idempotencia cuesta una tabla de claves, una comprobación en cada solicitud y limpieza (las claves se guardan normalmente alrededor de un día). No la añadas:
- a solicitudes que ya son idempotentes.
GET,PUTcon un valor,DELETE, repetidos, no hacen una segunda cosa; - donde ejecutar dos veces no hace daño. «Marcar como leído» dos veces es lo mismo que una.
Un cursor sobra cuando la lista es pequeña y casi no cambia (las categorías de la tienda), o cuando la gente de verdad salta a la página 7 (una tabla en un panel de administración). Ahí el offset es más simple y honesto.
Una versión de la API sobra mientras todos los clientes estén en el mismo repositorio y se publiquen junto con el servidor: entonces cámbialos juntos. Las versiones son para clientes que no controlas. Cada versión que se mantiene es código que tienes que probar y conservar, así que no hagas un v2 para un cambio que puede ser compatible.
Y no persigas el «REST puro» a cualquier precio. POST /orders/1001/cancel es más claro que un recurso artificial «cancelación». El contrato tiene que ser claro y estable, no bonito.
Cómo se opera un API gateway delante de los servicios (TLS, rate limiting, rutas) es un tema para el curso de DevOps ZeroToOps.
Más precisamente, para curiosos
Los estándares. El encabezado Idempotency-Key está descrito en un borrador del IETF («The Idempotency-Key HTTP Header Field»): la misma clave con otra solicitud es 422, y una repetición mientras la primera solicitud sigue en curso es 409. Stripe, Adyen y PayPal usan claves para sus pagos. Los errores en JSON suelen seguir el RFC 9457 (Problem Details): type, title, status, detail.
El contrato como archivo. OpenAPI describe las rutas, los campos y los estados en YAML. De ahí se generan clientes y documentación, y herramientas como oasdiff comparan dos versiones y dicen qué cambio rompe, igual que check en esta lección. Pact comprueba el contrato desde el lado del cliente (consumer-driven contract tests).
Por lenguaje. Spring Boot: @RestController, y springdoc-openapi construye OpenAPI a partir del código. Spring Data tiene Slice y Pageable para offset y ScrollPosition para un cursor.
No solo REST. GraphQL tiene una sola dirección y el cliente dice qué campos quiere, así que añadir campos es natural, y los cursores están en el estándar (Relay connections). gRPC describe el contrato en archivos .proto con campos numerados: un campo nuevo con número nuevo no rompe, un número reutilizado rompe.
Un cursor con valores repetidos en la clave. Si ordenas por fecha, dos pedidos pueden tener la misma fecha. Entonces el cursor es el par (fecha, id); si no, se pierden filas en el borde entre páginas.
Ejercicio
La misma clave, otro pago
Resuelve la tarea en tu editor.
Un cliente con un error envía la clave k-1 para un pago de 120 €, y luego la misma clave para un pago de 90 €. Ahora el servidor devuelve la respuesta guardada de 120 €, y el cliente cree que pagó 90 €. Cambia IdempotentApi para que recuerde también la solicitud: la misma clave con la misma solicitud devuelve la respuesta guardada, y la misma clave con otra solicitud es 422. Envía tres solicitudes (120 €, otra vez 120 €, 90 €) y el informe:
201 pagado 1001 (pay-1)201 pagado 1001 (pay-1)422 la clave k-1 es de otra solicitudpagos: 1, total 120 €Más tareas
- Caducidad. Guarda un tick de creación con la clave. Una clave de más de 100 ticks se olvida, y una solicitud con ella es nueva.
- Una respuesta a un error. Si el pago lanza un error (la tarjeta se rechaza), ¿se recuerda la respuesta? Decide y justifica: ¿debe un reintento tras
402intentarlo de nuevo? - Un cursor por fecha. Haz que los pedidos sean pares (fecha, id) con dos pedidos en la misma fecha y una página de 1. Muestra que un cursor solo por fecha pierde un pedido, y un cursor (fecha, id) no.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Streams · record, switch y patrones · Programación en red · Eventos y colas
- Continúa en: ADR: registros de decisiones de arquitectura · Cuando el vecino es lento: timeout, retry, circuit breaker · Una transacción entre servicios (Saga) · Integrar sistemas antiguos y ajenos
- Ejercicios sobre el tema: Arquitectura de software