13 - Problema resuelto: Servidor web asincrónico que consulta APIs externas (I/O-bound)

Objetivo: construir un mini-servidor que recibe peticiones HTTP, consulta varias APIs públicas en paralelo (bitcoin, clima, USD) y responde combinando los resultados. Se refuerza la asincronía para I/O-bound y el manejo de timeouts/errores.

13.1 Descripción del reto

Exponer /resumen que devuelva un JSON con precio BTC, clima y tipo de cambio USD en paralelo, con límites de tiempo para evitar que un proveedor lento bloquee la respuesta. /salud verifica disponibilidad.

13.2 Diseño y decisiones clave

  • Asincronía vs hilos: las llamadas son I/O-bound; usamos HttpClient.sendAsync y CompletableFuture en lugar de muchos hilos bloqueados.
  • Sesiones reutilizables: un HttpClient compartido por request, con timeout total.
  • Paralelismo controlado: CompletableFuture.allOf agrupa las llamadas en paralelo lógico; si alguna falla, no derriba las demás.
  • Timeouts y resiliencia: se aplica un timeout total (ej. 4 s) y se reporta error por proveedor.

13.3 Flujo de trabajo

  • Arrancar HttpServer con rutas /resumen y /salud.
  • Crear un HttpClient por request con timeout total.
  • Construir futures con sendAsync para cada API y recogerlos con allOf.
  • Normalizar respuestas: tiempos parciales, estado HTTP y errores.
  • Devolver JSON combinando resultados y tiempos totales.

13.4 Codificación (Java 21+)

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ServidorAsyncApis {
  private static final Map<String, String> APIS = Map.of(
      "btc", "https://api.coinbase.com/v2/prices/BTC-USD/spot",
      "clima", "https://api.open-meteo.com/v1/forecast?latitude=-34.61&longitude=-58.38¤t=temperature_2m",
      "usd", "https://api.exchangerate.host/latest?base=USD&symbols=EUR,ARS"
  );

  public static void main(String[] args) throws Exception {
    int port = Integer.parseInt(System.getProperty("port", "8080"));
    HttpServer server;
    try {
      server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0);
    } catch (java.net.BindException be) {
      System.err.println("Puerto " + port + " ocupado, intentando puerto libre...");
      server = HttpServer.create(new InetSocketAddress("0.0.0.0", 0), 0);
    }
    ExecutorService vThreads = Executors.newVirtualThreadPerTaskExecutor();
    server.setExecutor(vThreads);
    server.createContext("/resumen", new ResumenHandler());
    server.createContext("/salud", exchange -> responder(exchange, 200, "{\"status\":\"ok\"}"));
    server.start();
    System.out.println("Servidor en http://localhost:" + server.getAddress().getPort());
  }

  static class ResumenHandler implements HttpHandler {
    public void handle(HttpExchange exchange) throws IOException {
      if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
        responder(exchange, 405, "{\"error\":\"metodo no permitido\"}");
        return;
      }
      double timeoutS = leerTimeout(exchange.getRequestURI());
      Duration timeout = Duration.ofMillis((long) (timeoutS * 1000));
      Instant inicio = Instant.now();

      HttpClient client = HttpClient.newBuilder()
          .connectTimeout(timeout)
          .build();

      var tareas = APIS.entrySet().stream()
          .map(e -> llamarApi(client, e.getKey(), e.getValue(), timeout))
          .toArray(CompletableFuture[]::new);

      CompletableFuture.allOf(tareas).join();

      Map<String, Object> resultados = new LinkedHashMap<>();
      for (CompletableFuture<ResultadoApi> f : (CompletableFuture<ResultadoApi>[]) tareas) {
        ResultadoApi r = f.join();
        resultados.put(r.nombre(), r.data());
      }

      long msTotal = Duration.between(inicio, Instant.now()).toMillis();
      Map<String, Object> payload = new LinkedHashMap<>();
      payload.put("origen", "httpclient-sendAsync");
      payload.put("timeout_s", timeoutS);
      payload.put("asincronia", "Un hilo de servidor + futures; ideal para I/O-bound.");
      payload.put("multithreading", "Sin crear cientos de hilos; las esperas aparcan hilos virtuales.");
      payload.put("ms_total", msTotal);
      payload.put("resultados", resultados);

      responder(exchange, 200, Json.simple(payload));
    }
  }

  record ResultadoApi(String nombre, Map<String, Object> data) {}

  private static CompletableFuture<ResultadoApi> llamarApi(HttpClient client, String nombre, String url, Duration timeout) {
    Instant inicio = Instant.now();
    HttpRequest req = HttpRequest.newBuilder(URI.create(url)).timeout(timeout).GET().build();
    return client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
        .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
        .handle((resp, err) -> {
          Map<String, Object> res = new LinkedHashMap<>();
          res.put("ms", Duration.between(inicio, Instant.now()).toMillis());
          if (err != null) {
            res.put("ok", false);
            res.put("error", err.toString());
          } else if (resp.statusCode() >= 200 && resp.statusCode() < 300) {
            res.put("ok", true);
            res.put("status", resp.statusCode());
            String cuerpo = resp.body();
            res.put("body", esJson(cuerpo) ? new RawJson(cuerpo) : recortar(cuerpo, 400));
          } else {
            res.put("ok", false);
            res.put("status", resp.statusCode());
            res.put("error", "HTTP " + resp.statusCode());
          }
          return new ResultadoApi(nombre, res);
        });
  }

  private static double leerTimeout(URI uri) {
    String q = uri.getQuery();
    if (q == null) return 4.0;
    for (String part : q.split("&")) {
      String[] kv = part.split("=");
      if (kv.length == 2 && "timeout".equals(kv[0])) {
        try { return Double.parseDouble(kv[1]); } catch (NumberFormatException ignored) {}
      }
    }
    return 4.0;
  }

  private static void responder(HttpExchange ex, int status, String body) throws IOException {
    byte[] data = body.getBytes(StandardCharsets.UTF_8);
    ex.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8");
    ex.sendResponseHeaders(status, data.length);
    ex.getResponseBody().write(data);
    ex.close();
  }

  private static String recortar(String body, int max) {
    if (body == null) return "";
    return body.length() <= max ? body : body.substring(0, max) + "...";
  }

  private static boolean esJson(String body) {
    if (body == null) return false;
    String t = body.stripLeading();
    return t.startsWith("{") || t.startsWith("[");
  }
}

// Serializador JSON simple (sin dependencias externas)
class Json {
  static String simple(Map<String, Object> map) {
    StringBuilder sb = new StringBuilder("{");
    boolean first = true;
    for (Map.Entry<String, Object> e : map.entrySet()) {
      if (!first) sb.append(",");
      first = false;
      sb.append('"').append(e.getKey()).append('"').append(":");
      sb.append(toJsonValue(e.getValue()));
    }
    sb.append("}");
    return sb.toString();
  }

  @SuppressWarnings("unchecked")
  private static String toJsonValue(Object v) {
    if (v == null) return "null";
    if (v instanceof Number || v instanceof Boolean) return v.toString();
    if (v instanceof RawJson) return ((RawJson) v).raw;
    if (v instanceof Map) return simple((Map<String, Object>) v);
    String s = v.toString()
        .replace("\\", "\\\\")
        .replace("\"", "\\\"")
        .replace("\n", "\\n")
        .replace("\r", "\\r")
        .replace("\t", "\\t");
    return "\"" + s + "\"";
  }
}

// Marca para enviar JSON crudo sin escaparlo
class RawJson {
  final String raw;
  RawJson(String raw) { this.raw = raw; }
}

La lógica replica el tutorial en Python: sendAsync dispara las llamadas en paralelo, allOf espera todas y se arma un JSON con tiempos parciales. Al ser I/O-bound, los hilos virtuales evitan crear cientos de hilos nativos.

Flujo de llamadas asincrónicas a APIs

13.5 Cómo probar

  • Ejecuta el programa en Eclipse (Java 21+).
  • Abre el navegador y visita http://localhost:8080/resumen para ver el JSON combinado.
  • Prueba con timeout corto: http://localhost:8080/resumen?timeout=1 y observa los errores reportados.
  • Chequeo de salud: http://localhost:8080/salud en el navegador.

13.6 Qué se evalúa

  • Uso de CompletableFuture y allOf: corrutinas lógicas en paralelo con manejo de errores.
  • Diferencia asincronía vs multithreading: I/O-bound no necesita cientos de hilos; los futures e hilos virtuales mantienen ligero el servidor.
  • Resiliencia: timeout total, errores por proveedor, ruta de salud independiente.