Hasta ahora has trabajado en el nivel del transporte. Has abierto sockets, has diseñado BTCP/1 y BTDP/1 desde cero, has decidido dónde acaba cada mensaje y qué códigos devuelve el servidor. Eso está muy bien para entender cómo funcionan las cosas, pero tiene un problema evidente: nadie más habla tus protocolos. BTCP/1 solo lo entiende el cliente que escribiste tú.

Esta lección sube un nivel. Vamos a dejar de inventar protocolos y a usar el que ya domina el planeta: HTTP. Todo servicio externo con el que BiblioTech pueda querer hablar —un catálogo de metadatos por ISBN, un servicio de portadas, una pasarela de correo, cualquier API— habla HTTP. Y la buena noticia es que HTTP no es magia: es exactamente lo que viaja por el socket de 09-02, texto plano con un formato acordado, delimitado por líneas. Vas a verlo escrito byte a byte.

Java trae dos APIs para hablar HTTP. La antigua, HttpURLConnection, de 1996, es la que estudiamos hoy. La moderna, HttpClient de Java 11, es la de 09-06. Y conviene decirlo de entrada con honestidad: HttpURLConnection es una API vieja, verbosa y llena de trampas. No la usarías para escribir código nuevo. Pero está en toda la biblioteca estándar, aparece en montañas de código heredado, y sus conceptos —métodos, cabeceras, códigos de estado, flujos de entrada y de error— son los mismos que necesitarás en la API moderna. Aprenderla es entender HTTP con las manos.

Al terminar, BiblioTech consultará un servicio externo de metadatos por ISBN y se descargará la portada de un libro a disco.

Contenido

  1. Anatomía de una URL
  2. URL frente a URI
  3. Codificación de parámetros con URLEncoder
  4. HTTP a fondo: la petición
  5. HTTP a fondo: la respuesta
  6. Un intercambio real, byte a byte
  7. Métodos HTTP
  8. Códigos de estado
  9. Cabeceras clave
  10. El caso simple: URL.openStream()
  11. HttpURLConnection: el control real
  12. Tiempos límite: nunca sin ellos
  13. getInputStream frente a getErrorStream
  14. Enviar un cuerpo: POST con setDoOutput
  15. Redirecciones
  16. Compresión y HTTPS
  17. BiblioTech: cliente de metadatos y descarga de portadas
  18. La valoración honesta de esta API
  19. Errores Comunes y Consejos
  20. Ejercicios

  1. Anatomía de una URL

Una URL (localizador uniforme de recursos) dice dónde está algo y cómo llegar a ello. Tiene seis partes, y conviene saber nombrarlas todas.

 https://api.nexussoftware.com:8443/v1/libros/buscar?isbn=978-0000000001&formato=json#resumen
 \___/   \____________________/\__/\_________________/\____________________________/\______/
   |               |            |          |                      |                     |
esquema          host        puerto      ruta                 consulta              fragmento
Parte Ejemplo Notas
Esquema https El protocolo. Determina el puerto por defecto
Autoridad api.nexussoftware.com:8443 Host y puerto; puede incluir usuario:clave@ (obsoleto e inseguro)
Host api.nexussoftware.com Nombre o IP. Se resuelve por DNS (09-01)
Puerto 8443 Si se omite, el del esquema: 80 para http, 443 para https
Ruta /v1/libros/buscar Qué recurso se pide
Consulta isbn=978-...&formato=json Parámetros, clave=valor separados por &
Fragmento resumen Nunca se envía al servidor. Es para el cliente

Ese último punto sorprende a mucha gente: el fragmento (#algo) es puramente local. El navegador lo usa para desplazarse a una sección de la página; el servidor nunca lo ve. Si intentas usar el fragmento para pasar información a un servicio, no llegará.

En Java:

import java.net.URL;

URL url = new URL("https://api.nexussoftware.com:8443/v1/libros/buscar"
        + "?isbn=978-0000000001&formato=json#resumen");

System.out.println("Protocolo : " + url.getProtocol());   // https
System.out.println("Host      : " + url.getHost());       // api.nexussoftware.com
System.out.println("Puerto    : " + url.getPort());       // 8443
System.out.println("Por defecto: " + url.getDefaultPort()); // 443
System.out.println("Ruta      : " + url.getPath());       // /v1/libros/buscar
System.out.println("Consulta  : " + url.getQuery());      // isbn=978-...&formato=json
System.out.println("Fragmento : " + url.getRef());        // resumen
System.out.println("Fichero   : " + url.getFile());       // ruta + ? + consulta

Cuidado con getPort(). Devuelve -1 si el puerto no aparece explícitamente en la URL, no el puerto por defecto. Para obtener el puerto efectivo hay que combinarlo con getDefaultPort():

int puerto = url.getPort() != -1 ? url.getPort() : url.getDefaultPort();

Olvidarlo produce el clásico intento de conectar al puerto -1.

  1. URL frente a URI

Java tiene dos clases para esto y la diferencia importa.

java.net.URL java.net.URI
Qué representa Un recurso al que se puede acceder Un identificador, sin más
Sabe abrir conexiones (openConnection, openStream) No
Valida la sintaxis Poco , estrictamente
Necesita conocer el esquema : new URL("foo://x") lanza excepción No: cualquier esquema vale
equals() Hace resolución DNS (!) Comparación de texto
Normaliza rutas (.., .) No Sí, con normalize()
Desde qué versión 1.0 1.4

El punto que más muerde es el equals() de URL:

URL a = new URL("http://example.com/pagina");
URL b = new URL("http://93.184.216.34/pagina");

// Esta comparacion HACE UNA CONSULTA DNS y puede tardar segundos.
// Y devuelve true si ambos nombres resuelven a la misma direccion.
boolean iguales = a.equals(b);

URL.equals() y URL.hashCode() resuelven el host por DNS. Eso significa que:

  • Comparar dos URL puede bloquear durante segundos.
  • Meter URL en un HashSet o como clave de un HashMap provoca consultas DNS al insertar y al buscar.
  • Sin red, el comportamiento cambia.

Es un error de diseño reconocido, y la regla práctica es sencilla: usa URI para representar, manipular y comparar; convierte a URL solo en el momento de abrir la conexión.

import java.net.URI;
import java.net.URL;

// 1. Construir y manipular con URI: validacion estricta, sin DNS.
URI uri = new URI("https", "api.nexussoftware.com", "/v1/libros/buscar",
                  "isbn=978-0000000001", null);
//                 esquema  host          ruta        consulta        fragmento

System.out.println(uri);    // https://api.nexussoftware.com/v1/libros/buscar?isbn=978-0000000001

// 2. Convertir a URL solo para conectar.
URL url = uri.toURL();

URI tiene además utilidades que URL no tiene:

URI base = URI.create("https://api.nexussoftware.com/v1/");
URI completa = base.resolve("libros/978-0000000001");
// -> https://api.nexussoftware.com/v1/libros/978-0000000001

URI sucia = URI.create("https://api.nexussoftware.com/v1/../v2/./libros");
System.out.println(sucia.normalize());
// -> https://api.nexussoftware.com/v2/libros

Nota. En Java 20 se marcaron como obsoletos los constructores de URL, precisamente para empujar hacia URI.create(...).toURL(). Si compilas con una versión reciente y ves avisos de obsolescencia sobre new URL(...), esa es la razón, y la solución es la que ya recomendamos.

  1. Codificación de parámetros con URLEncoder

Una URL solo admite un conjunto restringido de caracteres. Los espacios, los acentos, los signos &, =, ? y #, y cualquier carácter no ASCII deben codificarse en la forma %XX, donde XX es el valor hexadecimal del byte.

Sin codificar, las cosas fallan de formas creativas:

Buscar el titulo "Patrones de Diseño":

  MAL:  /buscar?titulo=Patrones de Diseño
        -> El espacio rompe la peticion HTTP (el servidor cree que la ruta
           termina en "Patrones" y que "de" es la version del protocolo).
        -> La ñ, sin codificar, llega como bytes que el servidor puede
           interpretar en otra codificacion.

  BIEN: /buscar?titulo=Patrones+de+Dise%C3%B1o
        -> El espacio es +, y la ñ son sus dos bytes UTF-8: C3 B1.

Y el caso realmente peligroso: un valor que contenga & o = inyecta parámetros.

  Buscar autor = "Bloch & Gamma"

  MAL:  /buscar?autor=Bloch & Gamma&admin=true
        -> El servidor ve DOS parametros: autor="Bloch " y " Gamma"
        -> Y si el atacante escribe "&admin=true", inyecta un parametro.

  BIEN: /buscar?autor=Bloch+%26+Gamma

En Java:

import java.net.URLEncoder;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

String titulo = "Patrones de Diseño";
String codificado = URLEncoder.encode(titulo, StandardCharsets.UTF_8);
System.out.println(codificado);     // Patrones+de+Dise%C3%B1o

String vuelta = URLDecoder.decode(codificado, StandardCharsets.UTF_8);
System.out.println(vuelta);         // Patrones de Diseño

El charset es obligatorio. Existe una sobrecarga URLEncoder.encode(String) sin charset, obsoleta desde hace décadas, que usa la codificación de la plataforma. Con ella, la misma URL sale distinta en Linux y en Windows. Usa siempre la versión de dos argumentos con StandardCharsets.UTF_8, que además es lo que espera cualquier servidor moderno.

Una trampa importante

URLEncoder está pensado para valores de formulario (application/x-www-form-urlencoded), no para rutas. Su diferencia visible: codifica el espacio como +, no como %20.

Contexto Espacio Herramienta correcta
Valor de un parámetro de consulta + (o %20, ambos válidos) URLEncoder
Segmento de la ruta %20. Un + en la ruta es un + literal URI con constructor multiargumento
// MAL: en la ruta, el + no significa espacio.
String ruta = "/libros/" + URLEncoder.encode("Java Efectivo", UTF_8);
// -> /libros/Java+Efectivo   (el servidor buscara un libro con un + en el titulo)

// BIEN: dejar que URI codifique la ruta correctamente.
URI uri = new URI("https", "api.nexussoftware.com",
                  "/libros/Java Efectivo", null, null);
// -> https://api.nexussoftware.com/libros/Java%20Efectivo

El constructor multiargumento de URI codifica cada componente según sus reglas. Es la forma correcta y la que usaremos en BiblioTech.

Un ayudante para construir cadenas de consulta:

package com.nexussoftware.bibliotech.red;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Constructor de cadenas de consulta con codificacion correcta.
 * LinkedHashMap para que el orden sea predecible (util al depurar y al cachear).
 */
public class ConsultaUrl {

    private final Map<String, String> parametros = new LinkedHashMap<>();

    public ConsultaUrl con(String clave, String valor) {
        if (valor != null) {
            parametros.put(clave, valor);
        }
        return this;    // encadenable
    }

    public ConsultaUrl con(String clave, int valor) {
        parametros.put(clave, String.valueOf(valor));
        return this;
    }

    /** Devuelve "a=1&b=2" con todo codificado, o "" si no hay parametros. */
    public String construir() {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> e : parametros.entrySet()) {
            if (sb.length() > 0) {
                sb.append('&');
            }
            // Se codifican TANTO la clave COMO el valor: una clave con
            // caracteres raros rompe la peticion igual que un valor.
            sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8));
            sb.append('=');
            sb.append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
        }
        return sb.toString();
    }
}
String consulta = new ConsultaUrl()
        .con("titulo", "Patrones de Diseño")
        .con("autor", "Bloch & Gamma")
        .con("max", 10)
        .construir();
// titulo=Patrones+de+Dise%C3%B1o&autor=Bloch+%26+Gamma&max=10

  1. HTTP a fondo: la petición

Aquí llega el momento en que todo encaja. Una petición HTTP es texto plano enviado por un socket TCP, con el formato acordado. Exactamente lo que sabes hacer desde 09-02.

GET /v1/libros/978-0000000001 HTTP/1.1\r\n     <- linea de peticion
Host: api.nexussoftware.com\r\n                 <- cabeceras
Accept: application/json\r\n
User-Agent: BiblioTech/1.0\r\n
Connection: close\r\n
\r\n                                            <- LINEA VACIA: fin de cabeceras
                                                <- (aqui iria el cuerpo, si lo hubiera)

Cuatro elementos:

  1. Línea de petición: MÉTODO RUTA VERSIÓN. Separados por un espacio.
  2. Cabeceras: Nombre: valor, una por línea. Los nombres no distinguen mayúsculas.
  3. Línea vacía: marca el fin de las cabeceras. Obligatoria.
  4. Cuerpo (opcional): los datos, en POST, PUT, PATCH.

Detalles que importan:

  • El delimitador es \r\n, no \n. HTTP lo exige estrictamente. Si escribes esto a mano con un PrintWriter y println(), en Linux enviarás solo \n y algunos servidores lo rechazarán. Es exactamente la advertencia de 09-02 sobre el separador de línea de la plataforma.
  • La cabecera Host es obligatoria en HTTP/1.1. Es la que permite que una sola IP sirva cientos de sitios distintos (hosting virtual): el servidor decide qué sitio servir mirando esa cabecera.
  • La línea vacía es imprescindible. Sin ella, el servidor sigue esperando cabeceras y tu petición nunca se procesa: el mismo interbloqueo del flush olvidado de 09-02, por otra causa.

Puedes escribirlo a mano ahora mismo

printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' | nc example.com 80

Eso es un cliente HTTP completo escrito con nc. Y con lo que sabes de 09-02, podrías escribirlo en Java en veinte líneas. HTTP no es más que un protocolo de texto sobre TCP, como el BTCP/1 que diseñaste; la diferencia es que este lo entiende medio planeta.

  1. HTTP a fondo: la respuesta

La respuesta tiene la misma estructura, con la primera línea distinta.

HTTP/1.1 200 OK\r\n                             <- linea de estado
Content-Type: application/json; charset=utf-8\r\n  <- cabeceras
Content-Length: 87\r\n
Date: Wed, 05 Aug 2026 09:14:22 GMT\r\n
Server: nginx/1.24.0\r\n
\r\n                                            <- linea vacia
{"isbn":"978-0000000001","titulo":"Java Efectivo","autor":"Bloch","paginas":416}
  1. Línea de estado: VERSIÓN CÓDIGO TEXTO. El código es lo que importa; el texto es informativo.
  2. Cabeceras: igual que en la petición.
  3. Línea vacía.
  4. Cuerpo: el contenido.

¿Cómo sabe el cliente dónde acaba el cuerpo? Es la misma pregunta del problema del delimitador de 09-01, y HTTP la resuelve de tres formas:

Mecanismo Cómo Cuándo se usa
Content-Length: 87 Longitud previa: exactamente 87 bytes Lo habitual, cuando el tamaño se conoce
Transfer-Encoding: chunked Trozos, cada uno precedido de su tamaño en hexadecimal, y un trozo de tamaño 0 al final Cuando el tamaño no se conoce de antemano (contenido generado)
Cierre de la conexión El cuerpo acaba cuando el servidor cierra HTTP/1.0, o Connection: close sin longitud

Las tres técnicas del apartado sobre delimitadores de 09-01, todas juntas. HTTP es un caso de estudio de diseño de protocolos, y ahora tienes el contexto para apreciarlo.

  1. Un intercambio real, byte a byte

Vamos a verlo de verdad, sin Java de por medio. Arranca un servidor local que muestre lo que le llega:

Terminal 1:

nc -l 8080

Terminal 2:

curl -v http://localhost:8080/v1/libros/978-0000000001

En la terminal 1 aparece exactamente lo que curl ha enviado:

GET /v1/libros/978-0000000001 HTTP/1.1
Host: localhost:8080
User-Agent: curl/8.5.0
Accept: */*

Ahora teclea tú la respuesta en la terminal 1 (recuerda la línea vacía antes del cuerpo) y pulsa Ctrl+D:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 44

{"isbn":"978-0000000001","titulo":"Java Efectivo"}

Y en la terminal 2, curl muestra el intercambio completo:

* Connected to localhost (127.0.0.1) port 8080
> GET /v1/libros/978-0000000001 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 44
<
{"isbn":"978-0000000001","titulo":"Java Efectivo"}

Acabas de hacer de servidor HTTP con nc, igual que en 09-02 hiciste de servidor BTCP. Es el mismo ejercicio, con otro protocolo. Y deja clarísima la idea central de esta lección: HTTP es texto sobre un socket, y todo lo que hace HttpURLConnection es formatear ese texto por ti y analizar la respuesta.

sequenceDiagram
    participant C as Cliente (BiblioTech)
    participant S as Servidor de metadatos
    Note over C,S: Conexion TCP: saludo de tres vias (09-01)
    C->>S: GET /v1/libros/978-0000000001 HTTP/1.1
    C->>S: Host: api.nexussoftware.com
    C->>S: Accept: application/json
    C->>S: (linea vacia)
    Note over S: Procesa la peticion
    S-->>C: HTTP/1.1 200 OK
    S-->>C: Content-Type: application/json
    S-->>C: Content-Length: 87
    S-->>C: (linea vacia)
    S-->>C: {"isbn":"978-...","titulo":"Java Efectivo"}
    Note over C,S: Cierre, o reutilizacion si keep-alive

  1. Métodos HTTP

Método Para qué ¿Seguro? ¿Idempotente? ¿Cuerpo?
GET Obtener un recurso No
HEAD Como GET pero solo cabeceras No
POST Crear, o enviar datos a procesar No No
PUT Reemplazar un recurso completo No
PATCH Modificar parcialmente No No
DELETE Borrar un recurso No Raro
OPTIONS Qué se puede hacer con este recurso No

Las dos propiedades de la tabla tienen consecuencias prácticas directas:

Seguro significa que no modifica nada en el servidor. Un GET puede repetirse, cachearse y precargarse sin consecuencias. Por eso es un error grave usar GET para acciones que cambian algo: un rastreador web, o el precargado del navegador, ejecutarían la acción sin que nadie la pidiera.

Idempotente significa que repetirlo produce el mismo resultado que hacerlo una vez. Y esto decide si puedes reintentar tras un tiempo agotado:

  • GET, PUT, DELETE, HEAD: reintentar es seguro.
  • POST y PATCH: reintentar puede duplicar la operación.

Si envías un POST que registra un préstamo y se agota el tiempo de espera, no sabes si el servidor lo procesó o no. Reintentar puede crear dos préstamos. Es exactamente el mismo razonamiento que en 09-04 llevó a dejar PRESTAR en TCP en vez de UDP, aplicado aquí. La solución profesional es la clave de idempotencia: el cliente genera un identificador único, lo envía en una cabecera, y el servidor rechaza la segunda petición con la misma clave. Lo mencionamos en 09-06 al hablar de reintentos.

  1. Códigos de estado

El primer dígito indica la familia, y eso permite decidir sin leer el texto — la misma idea que aplicaste en BTCP/1.

Familia Significado Qué hacer
1xx Informativo Raro; casi nunca lo verás en la práctica
2xx Éxito Procesar la respuesta
3xx Redirección Ir a otro sitio (a menudo automático)
4xx Error del cliente Tú te has equivocado. No reintentes sin cambiar algo
5xx Error del servidor Él se ha equivocado. Reintentar puede tener sentido

Los que verás de verdad:

Código Nombre Cuándo
200 OK Todo bien
201 Created Recurso creado (respuesta típica a un POST)
204 No Content Bien, pero no hay cuerpo (típico de DELETE)
301 Moved Permanently Cambió de sitio para siempre. Actualiza tus enlaces
302 Found Cambió temporalmente
304 Not Modified No ha cambiado desde tu última consulta; usa tu caché
400 Bad Request Tu petición está mal formada
401 Unauthorized No te has autenticado. El nombre es engañoso
403 Forbidden Te has autenticado, pero no tienes permiso
404 Not Found El recurso no existe
405 Method Not Allowed Ese recurso no admite ese método
409 Conflict Conflicto de estado (el mismo 409 de BTCP/1)
429 Too Many Requests Límite de peticiones superado. Mira Retry-After
500 Internal Server Error Fallo genérico del servidor
502 Bad Gateway Un intermediario no obtuvo respuesta del servidor real
503 Service Unavailable Saturado o en mantenimiento. Suele ser temporal
504 Gateway Timeout Un intermediario agotó su espera

Cuáles merecen reintento (esto lo aplicarás en 09-06):

Código ¿Reintentar? Motivo
429 , esperando lo que diga Retry-After Es lo que el servidor te pide
502, 503, 504 , con espera creciente Fallos transitorios de infraestructura
500 Con cautela Puede ser un fallo determinista que se repetirá
4xx en general No Reintentar lo mismo dará lo mismo

Y el error clásico número uno con esta API, que merece su propio recuadro:

Un código 4xx o 5xx NO lanza excepción. Una respuesta 404 o 500 es una respuesta HTTP perfectamente válida y correctamente entregada. Desde el punto de vista de la red, todo ha ido bien. Hay que comprobar getResponseCode() a mano, siempre. Suponer que "si no hubo excepción, fue bien" es la causa de una cantidad enorme de bugs.

  1. Cabeceras clave

Cabecera Dirección Para qué
Host Petición Obligatoria en HTTP/1.1. Qué sitio se pide
Accept Petición Qué formatos acepta el cliente: application/json
Accept-Encoding Petición Qué compresiones acepta: gzip, deflate
Accept-Language Petición Idiomas preferidos: es-ES, es;q=0.9
User-Agent Petición Quién eres. Pon uno identificativo, no el de Java por defecto
Authorization Petición Credenciales: Bearer <token> o Basic <base64>
Content-Type Ambas Qué formato tiene el cuerpo
Content-Length Ambas Cuántos bytes tiene el cuerpo
Content-Encoding Respuesta Cómo viene comprimido el cuerpo
Location Respuesta A dónde ir en una redirección (3xx)
Retry-After Respuesta Cuántos segundos esperar antes de reintentar (429, 503)
Cache-Control Ambas Política de caché
ETag Respuesta Identificador de versión, para consultas condicionales
Set-Cookie / Cookie Respuesta / Petición Estado de sesión

Dos consejos concretos:

Pon un User-Agent identificativo. Por defecto Java envía algo como Java/17.0.9, que muchos servicios bloquean por asociarlo a rastreadores mal educados. Un BiblioTech/1.0 (+https://nexussoftware.com/bibliotech) te identifica y da a quien administra el servicio una forma de contactar si algo va mal.

Lee el Content-Type de la respuesta para saber el charset. Es la única forma correcta de decodificar el cuerpo:

Content-Type: application/json; charset=utf-8
                                ^^^^^^^^^^^^^

Si lo ignoras y supones UTF-8, funcionará el 95 % de las veces y te dará caracteres corruptos en el 5 % restante. Escribiremos un ayudante que lo extraiga.

  1. El caso simple: URL.openStream()

Para descargar algo sin más ceremonia:

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;

URL url = URI.create("http://localhost:8080/v1/libros/978-0000000001").toURL();

// openStream() = openConnection().getInputStream(), abreviado.
try (BufferedReader lector = new BufferedReader(
        new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) {
    String linea;
    while ((linea = lector.readLine()) != null) {
        System.out.println(linea);
    }
}

Reconocerás la pila: InputStreamReader con charset explícito sobre un InputStream, y un BufferedReader encima. Es la misma del módulo 7 y la misma de 09-02. Solo cambia de dónde sale el InputStream.

Sus limitaciones lo descartan para casi todo:

Limitación Consecuencia
Sin tiempo límite Puede bloquearse indefinidamente. Descalificador absoluto
Solo GET No sirve para enviar nada
No se pueden poner cabeceras Ni Accept, ni Authorization, ni User-Agent
No se ve el código de estado Un 404 lanza FileNotFoundException; un 500, IOException. Sin distinguir
No se ven las cabeceras de respuesta No sabes el Content-Type ni el charset

Sirve para un main de prueba o para leer un recurso local. Para cualquier otra cosa, HttpURLConnection.

  1. HttpURLConnection: el control real

El flujo de uso tiene un orden estricto que hay que respetar:

graph TD
    A["URI.create(...).toURL()"] --> B["url.openConnection()<br/>NO conecta todavia"]
    B --> C["cast a HttpURLConnection"]
    C --> D["setRequestMethod<br/>setRequestProperty<br/>setConnectTimeout<br/>setReadTimeout<br/>setDoOutput"]
    D --> E["escribir el cuerpo si es POST"]
    E --> F["getResponseCode()<br/>AQUI se conecta de verdad"]
    F -->|2xx o 3xx| G["getInputStream()"]
    F -->|4xx o 5xx| H["getErrorStream()"]
    G --> I["disconnect()"]
    H --> I

Los dos puntos que confunden a todo el mundo:

openConnection() no conecta. Devuelve un objeto de configuración. La conexión real ocurre en la primera llamada que necesita la respuesta: getResponseCode(), getInputStream() o getHeaderFields(). Por eso toda la configuración debe hacerse antes de esas llamadas; después, se ignora en silencio o lanza IllegalStateException.

Hay que hacer un cast. openConnection() declara devolver URLConnection, y todos los métodos HTTP están en la subclase HttpURLConnection.

Un ejemplo completo y correcto:

package com.nexussoftware.bibliotech.red;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

/** Petición HTTP GET con HttpURLConnection, hecha correctamente. */
public class EjemploGet {

    private static final Logger LOG = Logger.getLogger(EjemploGet.class.getName());

    public static void main(String[] args) throws IOException {
        URL url = URI.create("http://localhost:8080/v1/libros/978-0000000001").toURL();

        // 1. openConnection() NO conecta: devuelve un objeto de configuracion.
        HttpURLConnection conexion = (HttpURLConnection) url.openConnection();

        try {
            // 2. Configuracion. TODO esto debe ir ANTES de getResponseCode().
            conexion.setRequestMethod("GET");
            conexion.setRequestProperty("Accept", "application/json");
            conexion.setRequestProperty("User-Agent", "BiblioTech/1.0");
            conexion.setRequestProperty("Accept-Charset", "UTF-8");

            // NUNCA sin tiempos limite. Los dos, y son distintos (09-02).
            conexion.setConnectTimeout(5_000);      // establecer la conexion
            conexion.setReadTimeout(10_000);        // esperar datos

            // 3. AQUI se conecta de verdad y se lee la respuesta.
            int codigo = conexion.getResponseCode();
            String mensaje = conexion.getResponseMessage();
            System.out.println("Estado: " + codigo + " " + mensaje);

            // 4. Cabeceras de respuesta.
            System.out.println("Content-Type  : " + conexion.getContentType());
            System.out.println("Content-Length: " + conexion.getContentLengthLong());

            for (Map.Entry<String, List<String>> e : conexion.getHeaderFields().entrySet()) {
                // OJO: la clave null contiene la LINEA DE ESTADO.
                // Es una rareza de la API que sorprende la primera vez.
                String nombre = e.getKey() == null ? "(linea de estado)" : e.getKey();
                System.out.println("  " + nombre + ": " + e.getValue());
            }

            // 5. El cuerpo: getInputStream si fue bien, getErrorStream si no.
            //    ESTA distincion es el error clasico numero dos.
            InputStream cuerpo = (codigo >= 200 && codigo < 400)
                    ? conexion.getInputStream()
                    : conexion.getErrorStream();

            if (cuerpo == null) {
                System.out.println("(sin cuerpo)");
                return;
            }

            Charset charset = charsetDe(conexion.getContentType());
            try (BufferedReader lector = new BufferedReader(
                    new InputStreamReader(cuerpo, charset))) {
                String linea;
                while ((linea = lector.readLine()) != null) {
                    System.out.println(linea);
                }
            }

        } finally {
            // 6. disconnect() libera la conexion (o la devuelve al pool interno).
            conexion.disconnect();
        }
    }

    /**
     * Extrae el charset del Content-Type. Suponer UTF-8 funciona
     * casi siempre y falla justo cuando peor viene.
     */
    static Charset charsetDe(String contentType) {
        if (contentType != null) {
            for (String parte : contentType.split(";")) {
                String p = parte.strip();
                if (p.toLowerCase().startsWith("charset=")) {
                    String nombre = p.substring("charset=".length())
                            .replace("\"", "").strip();
                    try {
                        return Charset.forName(nombre);
                    } catch (Exception e) {
                        LOG.warning("Charset desconocido: " + nombre + "; se usa UTF-8");
                    }
                }
            }
        }
        return StandardCharsets.UTF_8;      // respaldo razonable
    }
}

Fíjate en la rareza de getHeaderFields(): la entrada con clave null contiene la línea de estado. Es un detalle de la API que aparece en cuanto la recorres y que desconcierta si no lo esperas.

Sobre disconnect(): su nombre engaña. No cierra necesariamente el socket, porque Java mantiene un pool de conexiones persistentes internamente. Lo que hace es indicar que has terminado con esa conexión, y si has consumido el cuerpo entero, la conexión puede reutilizarse. Si abandonas sin leer el cuerpo, se cierra de verdad y pierdes la reutilización. Por eso conviene leer siempre la respuesta completa, incluso cuando no te interese.

  1. Tiempos límite: nunca sin ellos

Ya lo has visto en 09-02 y en 09-03, pero con HTTP hay un agravante: estás hablando con un servicio de terceros que no controlas, en una red que no controlas.

conexion.setConnectTimeout(5_000);      // establecer la conexion TCP
conexion.setReadTimeout(10_000);        // esperar datos, por operacion
Tiempo límite Cubre Sin él
setConnectTimeout El saludo de tres vías Puede tardar más de un minuto en rendirse
setReadTimeout Cada operación de lectura Puede esperar indefinidamente

El valor por defecto de ambos es 0, que significa infinito. Un servicio externo que acepta la conexión y no responde nunca deja tu hilo bloqueado para siempre. Si eso ocurre en un hilo de un pool acotado, unos pocos casos agotan el pool y tu aplicación deja de funcionar sin un solo error en el log. Es exactamente el escenario que ya conoces de 09-03, con la diferencia de que aquí depende de un tercero.

Y una limitación que hay que conocer: setReadTimeout es por operación, no total. Un servidor que envíe un byte cada nueve segundos mantiene tu lectura viva indefinidamente sin agotar nunca un plazo de diez segundos. HttpURLConnection no tiene tiempo límite total de petición; HttpClient de 09-06 sí lo tiene con HttpRequest.timeout(), y es una de sus mejoras.

Valores razonables:

Tipo de servicio Conexión Lectura
Interno, misma red 1-2 s 3-5 s
Externo, API rápida 3-5 s 10 s
Externo, operación pesada 5 s 30-60 s
Descarga de fichero grande 5 s 30 s (por operación, no total)

  1. getInputStream frente a getErrorStream

El segundo error clásico, y de los que más tiempo hacen perder.

// CODIGO ROTO.
int codigo = conexion.getResponseCode();
InputStream entrada = conexion.getInputStream();   // <-- LANZA con un 404

Con un código 4xx o 5xx, getInputStream() lanza IOException (FileNotFoundException para el 404). Y lo grave es lo que se pierde: el cuerpo del error, que casi siempre contiene la explicación de qué has hecho mal.

HTTP/1.1 400 Bad Request
Content-Type: application/json

{"error":"isbn_invalido","mensaje":"El ISBN debe tener 13 digitos","campo":"isbn"}

Ese JSON es justo lo que necesitas para depurar, y getInputStream() te impide leerlo. La forma correcta:

int codigo = conexion.getResponseCode();

InputStream cuerpo = (codigo >= 200 && codigo < 400)
        ? conexion.getInputStream()
        : conexion.getErrorStream();

Con dos matices:

  • getErrorStream() puede devolver null si el servidor no envió cuerpo de error. Hay que comprobarlo.
  • getErrorStream() no lanza excepción, ni siquiera cuando no hay nada. Devuelve null y ya.

Un ayudante que resuelve el patrón de una vez por todas:

/**
 * Lee el cuerpo de la respuesta, venga por el flujo normal o por el de error.
 * Devuelve cadena vacia si no hay cuerpo.
 */
static String leerCuerpo(HttpURLConnection conexion, int codigo) throws IOException {
    InputStream entrada = (codigo >= 200 && codigo < 400)
            ? conexion.getInputStream()
            : conexion.getErrorStream();

    if (entrada == null) {
        return "";
    }

    Charset charset = charsetDe(conexion.getContentType());
    // readAllBytes es comodo pero SIN LIMITE: una respuesta gigante
    // agota la memoria. Se acota, como en 09-03.
    try (InputStream flujo = entrada) {
        byte[] bytes = flujo.readNBytes(MAXIMO_CUERPO);
        if (bytes.length == MAXIMO_CUERPO) {
            LOG.warning("Respuesta truncada a " + MAXIMO_CUERPO + " bytes");
        }
        return new String(bytes, charset);
    }
}

  1. Enviar un cuerpo: POST con setDoOutput

Para enviar datos hay que activar explícitamente la salida:

package com.nexussoftware.bibliotech.red;

import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;

/** POST con cuerpo JSON usando HttpURLConnection. */
public class EjemploPost {

    public static void main(String[] args) throws IOException {
        URL url = URI.create("http://localhost:8080/v1/prestamos").toURL();
        HttpURLConnection conexion = (HttpURLConnection) url.openConnection();

        try {
            conexion.setRequestMethod("POST");

            // setDoOutput(true) es lo que habilita getOutputStream().
            // OJO: ademas cambia el metodo por defecto a POST, asi que
            // si haces setDoOutput(true) tras un setRequestMethod("GET"),
            // la peticion sale como POST. Trampa clasica de esta API.
            conexion.setDoOutput(true);

            conexion.setRequestProperty("Content-Type", "application/json; charset=utf-8");
            conexion.setRequestProperty("Accept", "application/json");
            conexion.setRequestProperty("User-Agent", "BiblioTech/1.0");
            conexion.setConnectTimeout(5_000);
            conexion.setReadTimeout(10_000);

            // El cuerpo, construido a mano. Escapar las comillas y las
            // barras es imprescindible; hacerlo BIEN requiere una libreria
            // de JSON, y eso es 11-07.
            String json = "{\"isbn\":\"978-0000000001\",\"empleado\":\"Marta Ruiz\"}";
            byte[] cuerpo = json.getBytes(StandardCharsets.UTF_8);

            // Content-Length en BYTES, no en caracteres: con acentos
            // no coinciden, y un valor equivocado corrompe la peticion.
            conexion.setFixedLengthStreamingMode(cuerpo.length);

            try (OutputStream salida = conexion.getOutputStream()) {
                salida.write(cuerpo);
                salida.flush();     // el flush de siempre
            }

            int codigo = conexion.getResponseCode();
            System.out.println("Estado: " + codigo);
            System.out.println("Cuerpo: " + leerCuerpo(conexion, codigo));

        } finally {
            conexion.disconnect();
        }
    }
}

Cuatro puntos que merecen atención:

setDoOutput(true) cambia el método a POST implícitamente. Si escribes setRequestMethod("GET") y después setDoOutput(true), la petición sale como POST. Es una de las trampas más citadas de esta API.

Content-Length se mide en bytes. "Diseño".length() son 6 caracteres pero 7 bytes en UTF-8. Poner la longitud en caracteres corrompe la petición.

Los modos de envío del cuerpo:

Modo Cuándo Efecto
setFixedLengthStreamingMode(n) Se conoce el tamaño Envía con Content-Length. El preferible
setChunkedStreamingMode(n) No se conoce (contenido generado) Envía con Transfer-Encoding: chunked
Ninguno Por omisión Guarda todo el cuerpo en memoria antes de enviar. Malo para ficheros grandes

Escapar el JSON a mano es un apaño. El ejemplo funciona porque los valores son simples, pero un título con comillas o una barra invertida rompería el JSON. Construir y analizar JSON correctamente requiere una biblioteca —Jackson— y eso es 11-07. Aquí lo hacemos a mano y siendo conscientes de la limitación.

Formulario en lugar de JSON

conexion.setRequestProperty("Content-Type",
        "application/x-www-form-urlencoded; charset=utf-8");

// Aqui SI es correcto usar URLEncoder: es exactamente su formato.
String cuerpo = new ConsultaUrl()
        .con("isbn", "978-0000000001")
        .con("empleado", "Marta Ruiz")
        .construir();
// isbn=978-0000000001&empleado=Marta+Ruiz

  1. Redirecciones

Cuando el servidor responde 301, 302, 303, 307 o 308, la cabecera Location indica a dónde ir.

HttpURLConnection sigue las redirecciones automáticamente por defecto, lo cual es cómodo y a veces indeseable.

// Global, para toda la JVM. Evítalo: afecta a código que no es tuyo.
HttpURLConnection.setFollowRedirects(false);

// Por instancia. Este es el que debes usar.
conexion.setInstanceFollowRedirects(false);

Casos en que conviene desactivarlas:

  • Quieres saber que hubo redirección, por ejemplo para actualizar una URL guardada tras un 301.
  • Necesitas controlar el número de saltos, para no caer en un bucle infinito.
  • Envías credenciales: al seguir automáticamente, la cabecera Authorization podría enviarse a un host distinto del previsto. Es un riesgo de filtración real.

Y una limitación importante: HttpURLConnection no sigue redirecciones entre http y https. Si pides http://ejemplo.com y responde 301 hacia https://ejemplo.com, la biblioteca no sigue el salto y te devuelve el 301 sin cuerpo útil. Es una fuente inagotable de confusión, porque curl y los navegadores sí lo hacen.

/** Sigue redirecciones a mano, con limite y control de esquema. */
static HttpURLConnection seguirRedirecciones(URL url, int maximoSaltos)
        throws IOException {
    URL actual = url;
    for (int salto = 0; salto <= maximoSaltos; salto++) {
        HttpURLConnection conexion = (HttpURLConnection) actual.openConnection();
        conexion.setInstanceFollowRedirects(false);     // las gestionamos nosotros
        conexion.setConnectTimeout(5_000);
        conexion.setReadTimeout(10_000);

        int codigo = conexion.getResponseCode();
        if (codigo < 300 || codigo >= 400) {
            return conexion;        // no es redireccion: hemos llegado
        }

        String destino = conexion.getHeaderField("Location");
        conexion.disconnect();

        if (destino == null) {
            throw new IOException("Redireccion " + codigo + " sin cabecera Location");
        }
        // resolve() maneja las Location relativas ("/nueva/ruta"),
        // que son perfectamente legales y sorprenden si no se esperan.
        actual = actual.toURI().resolve(destino).toURL();
        LOG.info("Redireccion " + codigo + " -> " + actual);
    }
    throw new IOException("Demasiadas redirecciones (mas de " + maximoSaltos + ")");
}

  1. Compresión y HTTPS

Compresión con gzip

Un cuerpo JSON comprimido puede ocupar la quinta parte. HttpURLConnection anuncia gzip por defecto y lo descomprime solo... pero solo si tú no tocas la cabecera Accept-Encoding. Si la pones a mano, la descompresión automática se desactiva y recibes bytes comprimidos.

// Si pones esto A MANO, TIENES que descomprimir tu.
conexion.setRequestProperty("Accept-Encoding", "gzip");

int codigo = conexion.getResponseCode();
InputStream entrada = conexion.getInputStream();

if ("gzip".equalsIgnoreCase(conexion.getContentEncoding())) {
    entrada = new GZIPInputStream(entrada);     // decorador del modulo 7
}

GZIPInputStream es un decorador más, igual que BufferedInputStream. El módulo 7 sigue rindiendo.

Recomendación: no toques Accept-Encoding y deja que la biblioteca se ocupe. Solo hazlo si necesitas control explícito.

HTTPS

Si la URL empieza por https, openConnection() devuelve un HttpsURLConnection, subclase de HttpURLConnection. No hay que hacer nada más: el cifrado TLS, la validación del certificado y la comprobación del nombre de host ocurren de forma transparente.

URL url = URI.create("https://api.nexussoftware.com/v1/libros").toURL();
HttpsURLConnection conexion = (HttpsURLConnection) url.openConnection();

// ... igual que siempre ...

// Metodos adicionales, si necesitas inspeccionar el certificado:
System.out.println("Cifrado    : " + conexion.getCipherSuite());
System.out.println("Certificado: " + conexion.getServerCertificates()[0]);

Errores que verás y qué significan:

Excepción Causa Solución correcta
SSLHandshakeException: PKIX path building failed El certificado no está firmado por una autoridad que Java reconozca (autofirmado, o CA interna) Importar el certificado en el almacén de confianza, no desactivar la validación
SSLHandshakeException: No name matching X found El certificado es para otro nombre de host Usar el nombre correcto
SSLException: Received fatal alert: protocol_version Versiones de TLS incompatibles Actualizar el JDK o el servidor

Nunca desactives la validación de certificados. Verás por internet fragmentos de código con un TrustManager que acepta todo y un HostnameVerifier que devuelve true siempre. Eso elimina toda la seguridad de TLS: convierte HTTPS en HTTP con pasos extra, y deja la conexión abierta a un ataque de intermediario. Si tienes un certificado interno, la solución es importarlo en el almacén de confianza (keytool -importcert) o usar un SSLContext con un almacén propio. La seguridad de red se trata a fondo en 12-07.

  1. BiblioTech: cliente de metadatos y descarga de portadas

Ahora todo junto. Nexus Software tiene un servicio interno de metadatos bibliográficos, y BiblioTech va a consultarlo.

El contrato del servicio

GET /v1/libros/{isbn}
  200 -> {"isbn":"...","titulo":"...","autor":"...","paginas":416,
          "portada":"https://metadatos.nexussoftware.local/portadas/978-0000000001.jpg"}
  404 -> {"error":"no_encontrado","mensaje":"ISBN desconocido"}
  429 -> cabecera Retry-After con los segundos de espera

GET /portadas/{isbn}.jpg
  200 -> imagen JPEG binaria

El cliente

package com.nexussoftware.bibliotech.red;

import com.nexussoftware.bibliotech.excepcion.BiblioTechException;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Cliente del servicio externo de metadatos de Nexus Software,
 * con HttpURLConnection.
 *
 * Muestra el uso correcto de la API antigua: tiempos limite, comprobacion
 * explicita del codigo de estado, getErrorStream para los errores, lectura
 * acotada y traduccion a excepciones de dominio.
 */
public class ClienteMetadatos {

    private static final Logger LOG = Logger.getLogger(ClienteMetadatos.class.getName());

    private static final int LIMITE_CONEXION_MS = 5_000;
    private static final int LIMITE_LECTURA_MS = 10_000;
    /** Ninguna ficha de metadatos legitima pasa de esto. Defensa de memoria. */
    private static final int MAXIMO_CUERPO = 256 * 1024;
    /** Ninguna portada legitima pasa de esto. */
    private static final long MAXIMO_PORTADA = 5L * 1024 * 1024;
    private static final String AGENTE = "BiblioTech/1.0 (+https://nexussoftware.com)";

    private final String base;

    public ClienteMetadatos(String base) {
        // Se normaliza para poder concatenar rutas sin duplicar barras.
        this.base = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
    }

    /** Metadatos de un libro tal como los devuelve el servicio. */
    public record Metadatos(String isbn, String titulo, String autor,
                            int paginas, String urlPortada) {
    }

    // =================================================================
    // Consulta de metadatos
    // =================================================================

    /** Consulta un ISBN. Devuelve null si el servicio no lo conoce (404). */
    public Metadatos consultar(String isbn) throws BiblioTechException {
        validarIsbn(isbn);

        HttpURLConnection conexion = null;
        try {
            // El ISBN va en la RUTA, asi que se codifica con el constructor
            // multiargumento de URI, no con URLEncoder (que pondria + por espacio).
            URI uri = new URI("http", null, hostDe(base), puertoDe(base),
                    rutaDe(base) + "/v1/libros/" + isbn, null, null);
            URL url = uri.toURL();

            conexion = (HttpURLConnection) url.openConnection();
            conexion.setRequestMethod("GET");
            conexion.setRequestProperty("Accept", "application/json");
            conexion.setRequestProperty("User-Agent", AGENTE);
            conexion.setConnectTimeout(LIMITE_CONEXION_MS);
            conexion.setReadTimeout(LIMITE_LECTURA_MS);
            conexion.setInstanceFollowRedirects(true);

            // AQUI se conecta de verdad.
            int codigo = conexion.getResponseCode();
            LOG.fine(() -> "GET " + url + " -> " + codigo);

            // 404 no es un error del programa: es "no lo tengo".
            if (codigo == HttpURLConnection.HTTP_NOT_FOUND) {
                consumirYCerrar(conexion, codigo);
                return null;
            }

            // 429: el servicio nos pide que bajemos el ritmo.
            if (codigo == 429) {
                String espera = conexion.getHeaderField("Retry-After");
                consumirYCerrar(conexion, codigo);
                throw new BiblioTechException(
                        "El servicio de metadatos ha limitado las peticiones."
                        + (espera == null ? "" : " Reintentar en " + espera + " s."));
            }

            if (codigo != HttpURLConnection.HTTP_OK) {
                // El cuerpo del error casi siempre explica que ha pasado:
                // leerlo es la diferencia entre depurar en dos minutos
                // o en dos horas.
                String detalle = leerCuerpo(conexion, codigo);
                LOG.warning("Respuesta " + codigo + " del servicio: " + recortar(detalle));
                throw new BiblioTechException(
                        "El servicio de metadatos respondio " + codigo);
            }

            String cuerpo = leerCuerpo(conexion, codigo);
            return analizar(cuerpo, isbn);

        } catch (SocketTimeoutException e) {
            // TRANSITORIO: merece reintento con espera creciente.
            LOG.warning("Tiempo agotado consultando metadatos de " + isbn);
            throw new BiblioTechException(
                    "El servicio de metadatos no responde a tiempo.", e);

        } catch (UnknownHostException e) {
            // PERMANENTE: configuracion mal puesta.
            LOG.severe("Host de metadatos no resoluble: " + base);
            throw new BiblioTechException(
                    "No se encuentra el servicio de metadatos.", e);

        } catch (IOException | java.net.URISyntaxException e) {
            LOG.log(Level.WARNING, "Fallo consultando metadatos de " + isbn, e);
            throw new BiblioTechException(
                    "Error consultando el servicio de metadatos.", e);

        } finally {
            if (conexion != null) {
                conexion.disconnect();
            }
        }
    }

    // =================================================================
    // Descarga de la portada (binario a disco)
    // =================================================================

    /**
     * Descarga la portada a un fichero. Combina HTTP con NIO.2 del modulo 7.
     * Escritura ATOMICA: primero a un temporal, luego se mueve. Asi una
     * descarga interrumpida no deja un JPEG a medias en el catalogo.
     */
    public Path descargarPortada(String urlPortada, Path destino)
            throws BiblioTechException {

        HttpURLConnection conexion = null;
        Path temporal = null;

        try {
            URL url = URI.create(urlPortada).toURL();

            // Solo http y https: sin esto, una URL "file:///etc/passwd"
            // recibida del servicio nos haria leer ficheros locales.
            String esquema = url.getProtocol();
            if (!esquema.equals("http") && !esquema.equals("https")) {
                throw new BiblioTechException("Esquema no permitido: " + esquema);
            }

            conexion = (HttpURLConnection) url.openConnection();
            conexion.setRequestMethod("GET");
            conexion.setRequestProperty("Accept", "image/jpeg, image/png, image/*");
            conexion.setRequestProperty("User-Agent", AGENTE);
            conexion.setConnectTimeout(LIMITE_CONEXION_MS);
            conexion.setReadTimeout(30_000);        // una imagen tarda mas

            int codigo = conexion.getResponseCode();
            if (codigo != HttpURLConnection.HTTP_OK) {
                consumirYCerrar(conexion, codigo);
                throw new BiblioTechException(
                        "No se pudo descargar la portada: HTTP " + codigo);
            }

            // Content-Length es ORIENTATIVO: puede faltar (-1) o mentir.
            // Se comprueba antes Y durante la descarga.
            long anunciado = conexion.getContentLengthLong();
            if (anunciado > MAXIMO_PORTADA) {
                consumirYCerrar(conexion, codigo);
                throw new BiblioTechException(
                        "Portada demasiado grande: " + anunciado + " bytes");
            }

            String tipo = conexion.getContentType();
            if (tipo != null && !tipo.startsWith("image/")) {
                consumirYCerrar(conexion, codigo);
                throw new BiblioTechException("El recurso no es una imagen: " + tipo);
            }

            Files.createDirectories(destino.getParent());
            temporal = Files.createTempFile(destino.getParent(), "portada-", ".tmp");

            long descargados = 0;
            try (InputStream entrada = conexion.getInputStream();
                 OutputStream salida = Files.newOutputStream(temporal)) {

                byte[] bufer = new byte[8192];
                int leidos;
                while ((leidos = entrada.read(bufer)) != -1) {
                    descargados += leidos;
                    // El limite se comprueba TAMBIEN durante la descarga:
                    // el Content-Length puede mentir o no venir.
                    if (descargados > MAXIMO_PORTADA) {
                        throw new BiblioTechException(
                                "La portada supera el limite durante la descarga");
                    }
                    // write(bufer, 0, leidos): nunca bufer.length (modulo 7).
                    salida.write(bufer, 0, leidos);
                }
            }

            // Movimiento atomico: el fichero final aparece completo o no aparece.
            Files.move(temporal, destino,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
            temporal = null;    // ya no hay que limpiarlo

            long total = descargados;
            LOG.info(() -> "Portada descargada: " + destino + " (" + total + " bytes)");
            return destino;

        } catch (SocketTimeoutException e) {
            throw new BiblioTechException("Tiempo agotado descargando la portada.", e);

        } catch (IOException e) {
            LOG.log(Level.WARNING, "Fallo descargando " + urlPortada, e);
            throw new BiblioTechException("No se pudo descargar la portada.", e);

        } finally {
            if (conexion != null) {
                conexion.disconnect();
            }
            // Limpieza del temporal si algo fallo a mitad.
            if (temporal != null) {
                try {
                    Files.deleteIfExists(temporal);
                } catch (IOException e) {
                    LOG.fine("No se pudo borrar el temporal: " + temporal);
                }
            }
        }
    }

    // =================================================================
    // Utilidades
    // =================================================================

    private String leerCuerpo(HttpURLConnection conexion, int codigo) throws IOException {
        // La distincion que todo el mundo olvida: con 4xx/5xx hay que
        // usar getErrorStream, porque getInputStream LANZA.
        InputStream entrada = (codigo >= 200 && codigo < 400)
                ? conexion.getInputStream()
                : conexion.getErrorStream();

        if (entrada == null) {
            return "";
        }
        Charset charset = charsetDe(conexion.getContentType());
        try (InputStream flujo = entrada) {
            // readNBytes acotado, no readAllBytes: una respuesta enorme
            // no puede agotar nuestra memoria.
            byte[] bytes = flujo.readNBytes(MAXIMO_CUERPO);
            if (bytes.length == MAXIMO_CUERPO) {
                LOG.warning("Respuesta truncada a " + MAXIMO_CUERPO + " bytes");
            }
            return new String(bytes, charset);
        }
    }

    /**
     * Consume y cierra el cuerpo aunque no nos interese.
     * Sin esto, la conexion no vuelve al pool interno y se pierde
     * la reutilizacion, que en HTTP vale un viaje de red completo.
     */
    private void consumirYCerrar(HttpURLConnection conexion, int codigo) {
        try {
            InputStream entrada = (codigo >= 200 && codigo < 400)
                    ? conexion.getInputStream()
                    : conexion.getErrorStream();
            if (entrada != null) {
                try (InputStream flujo = entrada) {
                    flujo.readNBytes(MAXIMO_CUERPO);
                }
            }
        } catch (IOException e) {
            LOG.fine("Fallo consumiendo el cuerpo: " + e.getMessage());
        }
    }

    static Charset charsetDe(String contentType) {
        if (contentType != null) {
            for (String parte : contentType.split(";")) {
                String p = parte.strip();
                if (p.toLowerCase().startsWith("charset=")) {
                    String nombre = p.substring(8).replace("\"", "").strip();
                    try {
                        return Charset.forName(nombre);
                    } catch (Exception e) {
                        LOG.warning("Charset desconocido: " + nombre);
                    }
                }
            }
        }
        return StandardCharsets.UTF_8;
    }

    /**
     * Extraccion de campos de JSON POR BUSQUEDA DE SUBCADENA.
     *
     * ESTO ES UN APAÑO DIDACTICO, y hay que decirlo claramente.
     * Funciona con la respuesta concreta y sencilla de este servicio, y se
     * rompe con: valores que contengan la subcadena buscada, escapes (\"),
     * anidamiento, arrays, espacios distintos, campos en otro orden o
     * valores null. Analizar JSON de verdad requiere una libreria, y
     * eso se hace con JACKSON EN 11-07. No lleves esto a produccion.
     */
    private Metadatos analizar(String json, String isbnPedido)
            throws BiblioTechException {
        try {
            String titulo = campoTexto(json, "titulo");
            String autor = campoTexto(json, "autor");
            String portada = campoTexto(json, "portada");
            int paginas = campoEntero(json, "paginas");

            if (titulo == null) {
                throw new BiblioTechException(
                        "Respuesta del servicio sin el campo 'titulo'");
            }
            return new Metadatos(isbnPedido, titulo,
                    autor == null ? "(desconocido)" : autor,
                    paginas, portada);

        } catch (RuntimeException e) {
            throw new BiblioTechException(
                    "No se pudo interpretar la respuesta del servicio", e);
        }
    }

    /** Busca "campo":"valor" y devuelve el valor. Apaño, ver el comentario. */
    private String campoTexto(String json, String campo) {
        String marca = "\"" + campo + "\"";
        int i = json.indexOf(marca);
        if (i < 0) {
            return null;
        }
        int dosPuntos = json.indexOf(':', i + marca.length());
        if (dosPuntos < 0) {
            return null;
        }
        int abre = json.indexOf('"', dosPuntos);
        if (abre < 0) {
            return null;
        }
        int cierra = json.indexOf('"', abre + 1);
        if (cierra < 0) {
            return null;
        }
        return json.substring(abre + 1, cierra);
    }

    /** Busca "campo":123 y devuelve el numero, o 0 si no lo encuentra. */
    private int campoEntero(String json, String campo) {
        String marca = "\"" + campo + "\"";
        int i = json.indexOf(marca);
        if (i < 0) {
            return 0;
        }
        int dosPuntos = json.indexOf(':', i + marca.length());
        if (dosPuntos < 0) {
            return 0;
        }
        int j = dosPuntos + 1;
        while (j < json.length() && !Character.isDigit(json.charAt(j))) {
            if (json.charAt(j) == ',' || json.charAt(j) == '}') {
                return 0;
            }
            j++;
        }
        int inicio = j;
        while (j < json.length() && Character.isDigit(json.charAt(j))) {
            j++;
        }
        return inicio == j ? 0 : Integer.parseInt(json.substring(inicio, j));
    }

    private void validarIsbn(String isbn) throws BiblioTechException {
        if (isbn == null || isbn.isBlank() || isbn.length() > 20) {
            throw new BiblioTechException("ISBN no valido");
        }
        // Lista blanca: solo digitos y guiones. Impide inyectar rutas
        // ("../admin") o parametros ("?x=1") en la URL.
        for (int i = 0; i < isbn.length(); i++) {
            char c = isbn.charAt(i);
            if (!Character.isDigit(c) && c != '-') {
                throw new BiblioTechException("ISBN con caracteres no permitidos");
            }
        }
    }

    private String recortar(String texto) {
        return texto.length() > 200 ? texto.substring(0, 200) + "..." : texto;
    }

    // Descomposicion sencilla de la URL base para el constructor de URI.
    private String hostDe(String base) throws java.net.URISyntaxException {
        return new URI(base).getHost();
    }

    private int puertoDe(String base) throws java.net.URISyntaxException {
        return new URI(base).getPort();
    }

    private String rutaDe(String base) throws java.net.URISyntaxException {
        String p = new URI(base).getPath();
        return p == null ? "" : p;
    }
}

Probarlo sin servicio externo

Nexus Software no existe, así que hagamos de servicio con nc, como en 09-02:

Terminal 1:

printf 'HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 160\r\nConnection: close\r\n\r\n{"isbn":"978-0000000001","titulo":"Java Efectivo","autor":"Joshua Bloch","paginas":416,"portada":"http://localhost:8080/portadas/978-0000000001.jpg"}' | nc -l 8080

Terminal 2:

package com.nexussoftware.bibliotech.presentacion;

import com.nexussoftware.bibliotech.excepcion.BiblioTechException;
import com.nexussoftware.bibliotech.red.ClienteMetadatos;
import com.nexussoftware.bibliotech.red.ClienteMetadatos.Metadatos;

import java.nio.file.Path;

public class PruebaMetadatos {

    public static void main(String[] args) {
        ClienteMetadatos cliente = new ClienteMetadatos("http://localhost:8080");

        try {
            Metadatos m = cliente.consultar("978-0000000001");
            if (m == null) {
                System.out.println("El servicio no conoce ese ISBN");
                return;
            }
            System.out.println("Titulo  : " + m.titulo());
            System.out.println("Autor   : " + m.autor());
            System.out.println("Paginas : " + m.paginas());
            System.out.println("Portada : " + m.urlPortada());

            if (m.urlPortada() != null) {
                Path destino = Path.of("portadas", m.isbn() + ".jpg");
                cliente.descargarPortada(m.urlPortada(), destino);
                System.out.println("Guardada en " + destino.toAbsolutePath());
            }

        } catch (BiblioTechException e) {
            System.err.println("ERROR: " + e.getMessage());
            if (e.getCause() != null) {
                System.err.println("Causa: " + e.getCause());
            }
        }
    }
}
Titulo  : Java Efectivo
Autor   : Joshua Bloch
Paginas : 416
Portada : http://localhost:8080/portadas/978-0000000001.jpg

Pruebas que conviene hacer

  1. Responde con un 404 desde nc y comprueba que consultar devuelve null sin excepción.
  2. Responde con un 500 y un cuerpo de error JSON. Verás en el log el cuerpo del error gracias a getErrorStream(); con getInputStream() habrías tenido solo una IOException sin información.
  3. No respondas nada y espera. A los diez segundos salta el setReadTimeout. Quítalo y comprueba que espera indefinidamente.
  4. Compara con curl -v la misma petición. Es la forma de saber si el problema es tuyo o del servidor.
  5. Pon un ISBN con ../: cliente.consultar("../admin") se rechaza en la validación, antes de tocar la red.

  1. La valoración honesta de esta API

Has aprendido a usarla bien. Ahora la valoración sincera de por qué no la usarías para código nuevo.

Problema Detalle
Verbosidad Una petición sencilla son 30 líneas con su gestión de recursos
Configuración por efectos secundarios setDoOutput(true) cambia el método a POST sin decirlo
getInputStream frente a getErrorStream Una distinción que no debería existir y que todo el mundo olvida
Sin tiempo límite total Solo por operación: un servidor lento puede retenerte indefinidamente
No sigue redirecciones entre esquemas http→https falla, al contrario que en curl y los navegadores
Objeto mutable con estados Configurar tras conectar falla en silencio o lanza IllegalStateException
Sin asincronía Toda petición bloquea el hilo
Solo HTTP/1.1 Ni HTTP/2 ni multiplexación
Sin WebSocket Fuera de su alcance
URL.equals() hace DNS Consecuencia del diseño de 1996
Difícil de probar No hay interfaz que sustituir; hay que interceptar con URLStreamHandler

Todo esto lo resuelve HttpClient de Java 11, que verás en 09-06.

Entonces, ¿por qué aprenderla? Por tres razones sólidas:

  1. Está por todas partes. Millones de líneas de código Java en producción la usan. La leerás y la mantendrás.
  2. Enseña HTTP con las manos. Al ser verbosa, obliga a conocer los métodos, los códigos, las cabeceras y los flujos. Con HttpClient funciona todo tan bien que se puede usar sin entender qué pasa por debajo.
  3. Los conceptos se transfieren íntegros. Métodos, códigos de estado, cabeceras, tiempos límite, redirecciones, charsets: todo eso reaparece en la API moderna con mejor envoltorio. Lo difícil de HTTP no es la API, es HTTP.

Errores Comunes y Consejos

Suponer que un 404 o un 500 lanza excepción. El error número uno. Una respuesta de error es una respuesta válida y correctamente entregada. Comprueba getResponseCode() siempre.

Usar getInputStream() con un código de error. Lanza IOException y te quita el cuerpo del error, que es justo lo que explica el problema. Con 4xx y 5xx, getErrorStream().

No poner tiempos límite. Por defecto son infinitos. Un servicio externo que acepta y no responde bloquea tu hilo para siempre, y con un pool acotado, agota el pool sin un solo error en el log.

Configurar después de conectar. openConnection() no conecta, pero getResponseCode() sí. Toda configuración va antes.

Olvidar que setDoOutput(true) cambia el método a POST. Trampa clásica y silenciosa.

Calcular Content-Length con String.length(). Son caracteres, no bytes. Con acentos no coinciden y la petición sale corrupta. Usa getBytes(UTF_8).length.

Usar URLEncoder para segmentos de ruta. Codifica el espacio como +, que en una ruta es un + literal. Para rutas, el constructor multiargumento de URI.

Usar URLEncoder.encode(String) sin charset. Está obsoleto y usa la codificación de la plataforma: la misma URL sale distinta en cada sistema.

Comparar objetos URL o meterlos en un HashSet. equals() y hashCode() hacen resolución DNS: bloquean y dependen de la red. Usa URI.

Suponer UTF-8 sin leer el Content-Type. Funciona casi siempre y te da texto corrupto justo cuando no lo esperas.

Usar readAllBytes() sobre una respuesta remota. Sin límite de tamaño, una respuesta enorme —o maliciosa— agota tu memoria. readNBytes con un tope.

No consumir el cuerpo cuando no interesa. La conexión no vuelve al pool interno y pierdes la reutilización, que en HTTP cuesta un viaje de red completo.

Poner Accept-Encoding: gzip a mano y no descomprimir. Al tocar esa cabecera se desactiva la descompresión automática y recibes bytes comprimidos. O no la toques, o descomprime tú.

Desactivar la validación de certificados TLS. Elimina toda la seguridad de HTTPS y abre la puerta a un ataque de intermediario. Si el certificado es interno, impórtalo (12-07).

Descargar directamente al fichero final. Una descarga interrumpida deja un fichero corrupto que parece válido. Descarga a temporal y mueve al terminar.

Fiarse del Content-Length. Puede faltar (-1) o mentir. Comprueba el límite también durante la descarga.

Consejo de oro para depurar. Cuando algo no funcione, haz la misma petición con curl -v y compara. Si curl funciona y tu código no, la diferencia está en tus cabeceras o en el método. Y si necesitas ver lo que envía tu código Java, nc -l 8080 te lo muestra byte a byte — el mismo truco de 09-02, que sigue siendo la herramienta más eficaz que existe.

Ejercicios

Ejercicio 1: Inspector HTTP

Escribe una clase InspectorHttp con un método inspeccionar(String url) que muestre un informe completo de una URL, al estilo de curl -v pero en Java.

Requisitos:

  • Descomponer y mostrar todas las partes de la URL (esquema, host, puerto efectivo, ruta, consulta, fragmento).
  • Hacer primero una petición HEAD —que no descarga el cuerpo— y mostrar el código de estado, el mensaje y todas las cabeceras ordenadas.
  • Si el HEAD devuelve 405 (método no permitido, cosa que ocurre), reintentar con GET.
  • Desactivar el seguimiento automático de redirecciones y mostrar la cadena completa de saltos con sus códigos y sus Location, con un límite de 5.
  • Mostrar el tamaño del cuerpo, el tipo de contenido, el charset detectado y si viene comprimido.
  • Medir y mostrar el tiempo de conexión y el tiempo total.
  • Tiempos límite obligatorios y manejo diferenciado de las excepciones.

Pruébalo contra nc -l 8080 con respuestas que tú fabriques, incluyendo una cadena de dos redirecciones.

Ejercicio 2: Cliente con reintentos y respeto de Retry-After

Escribe ClienteHttpResistente, un envoltorio sobre HttpURLConnection que aplique una política de reintentos correcta.

Requisitos:

  • Método get(String url) que devuelva un record Respuesta(int codigo, String cuerpo, Map<String,List<String>> cabeceras, int intentos).
  • Reintentar solo en: SocketTimeoutException, ConnectException, y códigos 429, 502, 503, 504.
  • No reintentar nunca en 4xx (salvo 429) ni en UnknownHostException.
  • Espera creciente: 200 ms, 400, 800, 1600, con un máximo de 4 intentos.
  • Añadir a la espera una componente aleatoria de hasta el 20 % (jitter) y explicar en un comentario qué problema evita.
  • Si la respuesta trae Retry-After, respetarlo en lugar de la espera calculada, con un tope de 30 s. Acepta el formato en segundos (el de fecha puede ignorarse indicándolo).
  • Método post(String url, String cuerpo, String contentType) que no reintente por defecto, con un parámetro explícito para permitirlo, y un comentario explicando por qué POST es distinto.
  • Registro de cada intento con el logger.

Ejercicio 3: Sincronizador de portadas de BiblioTech

Escribe SincronizadorPortadas, que recorra el catálogo de BiblioTech y descargue las portadas que falten.

Requisitos:

  • Para cada material del catálogo sin portada local, consultar ClienteMetadatos y descargar la imagen.
  • Antes de descargar, hacer un HEAD para comprobar tipo y tamaño, y saltarse las que no sean imagen o superen 5 MB.
  • Descarga condicional: si el fichero local ya existe, enviar la cabecera If-Modified-Since con su fecha de modificación en formato HTTP y saltarse la descarga si el servidor responde 304 Not Modified. Consulta el formato de fecha HTTP y genéralo sin usar java.time (eso es 10-05): con SimpleDateFormat en zona GMT y Locale.US, indicando en un comentario que en 10-05 se hace mejor.
  • Descarga a fichero temporal y movimiento atómico.
  • Máximo 2 peticiones por segundo al servicio, para no saturarlo. Implementa el limitador de ritmo tú mismo.
  • Informe final: descargadas, ya al día (304), saltadas, fallidas, bytes totales y tiempo.
  • Todo secuencial: hacerlo en paralelo es 09-06, y lo mencionarás en un comentario.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.red;

import java.io.IOException;
import java.io.InputStream;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

/**
 * Inspector HTTP: informe completo de una URL, al estilo de curl -v.
 * Herramienta de diagnostico del equipo de sistemas de Nexus Software.
 */
public class InspectorHttp {

    private static final int MAXIMO_SALTOS = 5;
    private static final int LIMITE_CONEXION_MS = 5_000;
    private static final int LIMITE_LECTURA_MS = 10_000;
    private static final int MAXIMO_CUERPO = 1024 * 1024;

    /** Un salto de la cadena de redirecciones. */
    private record Salto(String url, int codigo, String mensaje, String destino) {
    }

    public void inspeccionar(String urlTexto) {
        System.out.println("=".repeat(78));
        System.out.println("INSPECCION DE: " + urlTexto);
        System.out.println("=".repeat(78));

        URL url;
        try {
            // URI valida la sintaxis estrictamente; URL no.
            url = URI.create(urlTexto).toURL();
        } catch (Exception e) {
            System.out.println("URL invalida: " + e.getMessage());
            return;
        }

        mostrarPartes(url);

        List<Salto> cadena = new ArrayList<>();
        URL actual = url;
        HttpURLConnection conexion = null;

        try {
            // --- Seguir la cadena de redirecciones a mano ---
            for (int salto = 0; salto <= MAXIMO_SALTOS; salto++) {
                conexion = abrir(actual, "HEAD");

                long t0 = System.nanoTime();
                int codigo = conexion.getResponseCode();
                long msConexion = (System.nanoTime() - t0) / 1_000_000;

                // Muchos servidores no admiten HEAD y devuelven 405.
                // Se reintenta con GET, que siempre esta soportado.
                if (codigo == HttpURLConnection.HTTP_BAD_METHOD) {
                    System.out.println("\n(HEAD devolvio 405; se reintenta con GET)");
                    conexion.disconnect();
                    conexion = abrir(actual, "GET");
                    t0 = System.nanoTime();
                    codigo = conexion.getResponseCode();
                    msConexion = (System.nanoTime() - t0) / 1_000_000;
                }

                String destino = conexion.getHeaderField("Location");
                cadena.add(new Salto(actual.toString(), codigo,
                        conexion.getResponseMessage(), destino));

                if (codigo < 300 || codigo >= 400 || destino == null) {
                    mostrarRespuesta(conexion, codigo, msConexion);
                    break;
                }

                // Location puede ser relativa: resolve() lo maneja.
                actual = actual.toURI().resolve(destino).toURL();
                conexion.disconnect();
                conexion = null;
            }

            mostrarCadena(cadena);

        } catch (UnknownHostException e) {
            System.out.println("\nNO RESUELVE el host: " + e.getMessage());
        } catch (ConnectException e) {
            System.out.println("\nCONEXION RECHAZADA: no hay servidor en ese puerto");
        } catch (SocketTimeoutException e) {
            System.out.println("\nTIEMPO AGOTADO: el servidor no responde");
        } catch (Exception e) {
            System.out.println("\nFALLO: " + e);
        } finally {
            if (conexion != null) {
                conexion.disconnect();
            }
        }
    }

    private HttpURLConnection abrir(URL url, String metodo) throws IOException {
        HttpURLConnection c = (HttpURLConnection) url.openConnection();
        c.setRequestMethod(metodo);
        c.setRequestProperty("User-Agent", "BiblioTech-Inspector/1.0");
        c.setRequestProperty("Accept", "*/*");
        c.setConnectTimeout(LIMITE_CONEXION_MS);
        c.setReadTimeout(LIMITE_LECTURA_MS);
        // Las gestionamos nosotros, para poder mostrarlas.
        c.setInstanceFollowRedirects(false);
        return c;
    }

    private void mostrarPartes(URL url) {
        // getPort() devuelve -1 si no viene explicito: hay que combinarlo
        // con getDefaultPort() para saber el puerto efectivo.
        int puerto = url.getPort() != -1 ? url.getPort() : url.getDefaultPort();

        System.out.println("\n--- PARTES DE LA URL ---");
        System.out.printf("  %-16s %s%n", "Esquema", url.getProtocol());
        System.out.printf("  %-16s %s%n", "Host", url.getHost());
        System.out.printf("  %-16s %d%s%n", "Puerto", puerto,
                url.getPort() == -1 ? "  (por defecto del esquema)" : "  (explicito)");
        System.out.printf("  %-16s %s%n", "Ruta",
                url.getPath().isEmpty() ? "/" : url.getPath());
        System.out.printf("  %-16s %s%n", "Consulta",
                url.getQuery() == null ? "(ninguna)" : url.getQuery());
        System.out.printf("  %-16s %s%n", "Fragmento",
                url.getRef() == null ? "(ninguno)"
                        : url.getRef() + "  <- NO se envia al servidor");
    }

    private void mostrarRespuesta(HttpURLConnection c, int codigo, long ms)
            throws IOException {
        System.out.println("\n--- RESPUESTA ---");
        System.out.printf("  %-16s %d %s   (%s)%n", "Estado", codigo,
                c.getResponseMessage(), familia(codigo));
        System.out.printf("  %-16s %d ms%n", "Tiempo", ms);

        System.out.println("\n--- CABECERAS ---");
        // TreeMap para orden alfabetico. La clave null lleva la linea de estado.
        Map<String, List<String>> cabeceras = new TreeMap<>((a, b) -> {
            if (a == null) return -1;
            if (b == null) return 1;
            return a.compareToIgnoreCase(b);
        });
        cabeceras.putAll(c.getHeaderFields());

        for (Map.Entry<String, List<String>> e : cabeceras.entrySet()) {
            String nombre = e.getKey() == null ? "(linea de estado)" : e.getKey();
            for (String valor : e.getValue()) {
                System.out.printf("  %-24s %s%n", nombre + ":", valor);
            }
        }

        System.out.println("\n--- CONTENIDO ---");
        System.out.printf("  %-16s %s%n", "Tipo",
                c.getContentType() == null ? "(no indicado)" : c.getContentType());
        System.out.printf("  %-16s %s%n", "Charset",
                ClienteMetadatos.charsetDe(c.getContentType()));
        long longitud = c.getContentLengthLong();
        System.out.printf("  %-16s %s%n", "Longitud",
                longitud < 0 ? "(no indicada: chunked o cierre)" : longitud + " bytes");
        System.out.printf("  %-16s %s%n", "Compresion",
                c.getContentEncoding() == null ? "(ninguna)" : c.getContentEncoding());

        // Con HEAD no hay cuerpo, pero con el GET de respaldo si.
        if ("GET".equals(c.getRequestMethod())) {
            InputStream entrada = (codigo >= 200 && codigo < 400)
                    ? c.getInputStream() : c.getErrorStream();
            if (entrada != null) {
                try (InputStream flujo = entrada) {
                    byte[] bytes = flujo.readNBytes(MAXIMO_CUERPO);
                    System.out.printf("  %-16s %d bytes leidos%n",
                            "Cuerpo real", bytes.length);
                    String texto = new String(bytes,
                            ClienteMetadatos.charsetDe(c.getContentType()));
                    System.out.println("\n--- PRIMERAS LINEAS DEL CUERPO ---");
                    String[] lineas = texto.split("\n", 6);
                    for (int i = 0; i < Math.min(5, lineas.length); i++) {
                        // Recortamos: nunca volcar sin limite datos de la red.
                        String l = lineas[i];
                        System.out.println("  " + (l.length() > 100
                                ? l.substring(0, 100) + "..." : l));
                    }
                }
            }
        }
    }

    private void mostrarCadena(List<Salto> cadena) {
        if (cadena.size() <= 1) {
            return;
        }
        System.out.println("\n--- CADENA DE REDIRECCIONES (" + cadena.size() + ") ---");
        for (int i = 0; i < cadena.size(); i++) {
            Salto s = cadena.get(i);
            System.out.printf("  %d. %d %s%n     %s%n", i + 1, s.codigo(),
                    s.mensaje(), s.url());
            if (s.destino() != null) {
                System.out.println("     -> Location: " + s.destino());
            }
        }
    }

    private String familia(int codigo) {
        return switch (codigo / 100) {
            case 1 -> "informativo";
            case 2 -> "EXITO";
            case 3 -> "redireccion";
            case 4 -> "ERROR DEL CLIENTE";
            case 5 -> "ERROR DEL SERVIDOR";
            default -> "desconocido";
        };
    }

    public static void main(String[] args) {
        InspectorHttp inspector = new InspectorHttp();
        inspector.inspeccionar(args.length > 0 ? args[0]
                : "http://localhost:8080/v1/libros/978-0000000001?formato=json#resumen");
    }
}

Salida contra un nc que responde una redirección y luego un 200:

==============================================================================
INSPECCION DE: http://localhost:8080/v1/libros/978-0000000001?formato=json#resumen
==============================================================================

--- PARTES DE LA URL ---
  Esquema          http
  Host             localhost
  Puerto           8080  (explicito)
  Ruta             /v1/libros/978-0000000001
  Consulta         formato=json
  Fragmento        resumen  <- NO se envia al servidor

--- RESPUESTA ---
  Estado           200 OK   (EXITO)
  Tiempo           4 ms

--- CABECERAS ---
  (linea de estado):       HTTP/1.1 200 OK
  Content-Length:          49
  Content-Type:            application/json; charset=utf-8

--- CONTENIDO ---
  Tipo             application/json; charset=utf-8
  Charset          UTF-8
  Longitud         49 bytes
  Compresion       (ninguna)

--- CADENA DE REDIRECCIONES (2) ---
  1. 302 Found
     http://localhost:8080/v1/libros/978-0000000001?formato=json
     -> Location: /v2/libros/978-0000000001
  2. 200 OK
     http://localhost:8080/v2/libros/978-0000000001

Comentarios. Cuatro detalles que este ejercicio enseña. El fragmento no aparece en ninguna cabecera enviada: se muestra en las partes de la URL y luego desaparece, que es exactamente el comportamiento correcto. getPort() devuelve -1 cuando el puerto no viene explícito y hay que combinarlo con getDefaultPort() — es un error habitual intentar conectar al puerto -1. La Location puede ser relativa (/v2/libros/...), lo cual es perfectamente legal según el estándar y rompe cualquier código que la trate como URL absoluta; URI.resolve() lo maneja. Y el 405 con HEAD ocurre en la práctica más de lo que parece: bastantes servidores solo implementan GET, y una herramienta de diagnóstico tiene que preverlo.

Solución 2

package com.nexussoftware.bibliotech.red;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.logging.Logger;

/**
 * Cliente HTTP con politica de reintentos correcta.
 *
 * La regla que lo gobierna todo: se reintenta lo TRANSITORIO
 * (tiempos agotados, 429, 5xx de infraestructura) y nunca lo
 * PERMANENTE (4xx, host inexistente).
 */
public class ClienteHttpResistente {

    private static final Logger LOG =
            Logger.getLogger(ClienteHttpResistente.class.getName());

    private static final int MAXIMO_INTENTOS = 4;
    private static final int ESPERA_INICIAL_MS = 200;
    private static final int MAXIMO_RETRY_AFTER_S = 30;
    private static final int MAXIMO_CUERPO = 1024 * 1024;
    private static final int LIMITE_CONEXION_MS = 5_000;
    private static final int LIMITE_LECTURA_MS = 10_000;

    public record Respuesta(int codigo, String cuerpo,
                            Map<String, List<String>> cabeceras, int intentos) {

        public boolean exito() {
            return codigo >= 200 && codigo < 300;
        }
    }

    // =================================================================
    // GET: idempotente, se reintenta
    // =================================================================

    public Respuesta get(String url) throws IOException {
        return ejecutar(url, "GET", null, null, true);
    }

    // =================================================================
    // POST: NO idempotente, NO se reintenta por defecto
    // =================================================================

    /**
     * POST sin reintentos.
     *
     * POR QUE ES DISTINTO: si un POST agota el tiempo de espera, NO SABEMOS
     * si el servidor lo proceso. Puede que la peticion llegara, se ejecutara
     * y solo se perdiera la respuesta. Reintentar duplicaria la operacion:
     * dos prestamos, dos cargos, dos pedidos.
     *
     * La solucion profesional es la CLAVE DE IDEMPOTENCIA: el cliente genera
     * un identificador unico por operacion, lo envia en una cabecera
     * (Idempotency-Key) y el servidor rechaza la segunda peticion con la
     * misma clave. Con eso, reintentar SI es seguro. Sin eso, no lo es.
     */
    public Respuesta post(String url, String cuerpo, String contentType)
            throws IOException {
        return ejecutar(url, "POST", cuerpo, contentType, false);
    }

    /** POST con reintentos: solo si el servicio garantiza idempotencia. */
    public Respuesta postIdempotente(String url, String cuerpo, String contentType)
            throws IOException {
        return ejecutar(url, "POST", cuerpo, contentType, true);
    }

    // =================================================================
    // Nucleo
    // =================================================================

    private Respuesta ejecutar(String urlTexto, String metodo, String cuerpo,
                               String contentType, boolean reintentar)
            throws IOException {

        URL url = URI.create(urlTexto).toURL();
        int espera = ESPERA_INICIAL_MS;
        IOException ultimoFallo = null;
        int maximo = reintentar ? MAXIMO_INTENTOS : 1;

        for (int intento = 1; intento <= maximo; intento++) {
            HttpURLConnection conexion = null;
            try {
                conexion = (HttpURLConnection) url.openConnection();
                conexion.setRequestMethod(metodo);
                conexion.setRequestProperty("User-Agent", "BiblioTech/1.0");
                conexion.setRequestProperty("Accept", "application/json, */*");
                conexion.setConnectTimeout(LIMITE_CONEXION_MS);
                conexion.setReadTimeout(LIMITE_LECTURA_MS);

                if (cuerpo != null) {
                    conexion.setDoOutput(true);
                    conexion.setRequestProperty("Content-Type",
                            contentType == null ? "application/json; charset=utf-8"
                                    : contentType);
                    byte[] bytes = cuerpo.getBytes(StandardCharsets.UTF_8);
                    // Longitud en BYTES, no en caracteres.
                    conexion.setFixedLengthStreamingMode(bytes.length);
                    try (OutputStream salida = conexion.getOutputStream()) {
                        salida.write(bytes);
                        salida.flush();
                    }
                }

                int codigo = conexion.getResponseCode();
                Map<String, List<String>> cabeceras = conexion.getHeaderFields();

                final int n = intento;
                LOG.fine(() -> metodo + " " + url + " -> " + codigo
                        + " (intento " + n + ")");

                // Codigo transitorio: merece reintento si queda margen.
                if (esTransitorio(codigo) && intento < maximo) {
                    long esperaMs = esperaTrasCodigo(conexion, codigo, espera);
                    consumir(conexion, codigo);
                    LOG.warning(metodo + " " + url + " -> " + codigo
                            + "; reintento " + (intento + 1) + " en " + esperaMs + " ms");
                    dormir(esperaMs);
                    espera *= 2;
                    continue;
                }

                String texto = leerCuerpo(conexion, codigo);
                return new Respuesta(codigo, texto, cabeceras, intento);

            } catch (UnknownHostException e) {
                // PERMANENTE: reintentar no va a hacer que el nombre exista.
                LOG.severe("Host no resoluble: " + url.getHost());
                throw e;

            } catch (SocketTimeoutException | ConnectException e) {
                // TRANSITORIOS: pueden resolverse solos.
                ultimoFallo = e;
                if (intento >= maximo) {
                    break;
                }
                long esperaMs = conJitter(espera);
                LOG.warning(e.getClass().getSimpleName() + " en " + url
                        + "; reintento " + (intento + 1) + " en " + esperaMs + " ms");
                dormir(esperaMs);
                espera *= 2;

            } finally {
                if (conexion != null) {
                    conexion.disconnect();
                }
            }
        }

        throw ultimoFallo != null ? ultimoFallo
                : new IOException("Sin respuesta tras " + maximo + " intentos");
    }

    // =================================================================
    // Politica de reintentos
    // =================================================================

    private boolean esTransitorio(int codigo) {
        // 429: nos piden bajar el ritmo. 502/503/504: fallos de infraestructura.
        // El 500 NO se incluye: suele ser un fallo determinista que se repetira.
        // Los 4xx tampoco: la peticion esta mal y reintentarla dara lo mismo.
        return codigo == 429 || codigo == 502 || codigo == 503 || codigo == 504;
    }

    /** Si el servidor dice cuanto esperar, se le hace caso. */
    private long esperaTrasCodigo(HttpURLConnection conexion, int codigo, int calculada) {
        String retryAfter = conexion.getHeaderField("Retry-After");
        if (retryAfter != null) {
            try {
                // Retry-After admite dos formatos: segundos, o una fecha HTTP.
                // Aqui solo tratamos el de segundos; el de fecha requiere
                // analizar fechas, y eso se hace bien en 10-05.
                long segundos = Long.parseLong(retryAfter.strip());
                long topeado = Math.min(segundos, MAXIMO_RETRY_AFTER_S);
                LOG.info("El servidor pide esperar " + segundos
                        + " s (aplicamos " + topeado + " s)");
                return topeado * 1000;
            } catch (NumberFormatException e) {
                LOG.fine("Retry-After en formato de fecha; se ignora: " + retryAfter);
            }
        }
        return conJitter(calculada);
    }

    /**
     * Anade una componente aleatoria de hasta el 20 %.
     *
     * QUE PROBLEMA EVITA: el "rebano atronador". Si cien clientes fallan a la
     * vez porque el servidor se cayo, y todos reintentan exactamente a los
     * 200 ms, las cien peticiones vuelven a llegar juntas y lo tumban otra
     * vez, en un ciclo que se repite indefinidamente. Desperdigar los
     * reintentos rompe la sincronizacion y reparte la carga en el tiempo.
     */
    private long conJitter(long base) {
        long variacion = (long) (base * 0.2);
        return base + ThreadLocalRandom.current().nextLong(-variacion, variacion + 1);
    }

    private void dormir(long ms) throws IOException {
        try {
            Thread.sleep(Math.max(0, ms));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();     // 08-02: restaurar la bandera
            throw new IOException("Reintento interrumpido", e);
        }
    }

    // =================================================================
    // Utilidades
    // =================================================================

    private String leerCuerpo(HttpURLConnection conexion, int codigo) throws IOException {
        InputStream entrada = (codigo >= 200 && codigo < 400)
                ? conexion.getInputStream() : conexion.getErrorStream();
        if (entrada == null) {
            return "";
        }
        try (InputStream flujo = entrada) {
            byte[] bytes = flujo.readNBytes(MAXIMO_CUERPO);
            return new String(bytes, ClienteMetadatos.charsetDe(conexion.getContentType()));
        }
    }

    /** Consume el cuerpo para que la conexion vuelva al pool interno. */
    private void consumir(HttpURLConnection conexion, int codigo) {
        try {
            InputStream entrada = (codigo >= 200 && codigo < 400)
                    ? conexion.getInputStream() : conexion.getErrorStream();
            if (entrada != null) {
                try (InputStream flujo = entrada) {
                    flujo.readNBytes(MAXIMO_CUERPO);
                }
            }
        } catch (IOException e) {
            LOG.fine("Fallo consumiendo el cuerpo: " + e.getMessage());
        }
    }
}

Prueba con un nc que responda 503 con Retry-After:

WARNING: GET http://localhost:8080/v1/libros -> 503; reintento 2 en 2000 ms
INFO: El servidor pide esperar 2 s (aplicamos 2 s)
WARNING: GET http://localhost:8080/v1/libros -> 503; reintento 3 en 431 ms
Respuesta[codigo=200, intentos=3]

Comentarios. Tres ideas centrales.

La tabla de qué se reintenta es el corazón del ejercicio, y cada exclusión tiene su razón. Los 4xx no se reintentan porque la petición está mal formada y volver a enviar lo mismo dará lo mismo. El 500 se excluye aunque sea 5xx porque suele indicar un fallo determinista —un error de programación en el servidor— que se repetirá idénticamente. UnknownHostException no se reintenta porque un nombre que no existe no va a empezar a existir en 400 ms.

El jitter parece un detalle menor y no lo es. Sin él, cien clientes que fallan simultáneamente reintentan simultáneamente, y la ráfaga sincronizada vuelve a tumbar el servicio que se estaba recuperando. Se repite indefinidamente. Con un 20 % de aleatoriedad, los reintentos se desperdigan y el servidor recibe una carga gradual que sí puede absorber. Es una técnica estándar en cualquier sistema distribuido serio.

Respetar Retry-After con un tope combina cortesía y prudencia: se hace caso al servidor, que sabe mejor que tú cuándo estará listo, pero con un límite, porque un Retry-After: 3600 no puede dejar tu hilo bloqueado una hora. Y fíjate en que el formato de fecha se ignora explícitamente indicando por qué: analizar fechas HTTP correctamente es trabajo de java.time, que es 10-05.

Solución 3

package com.nexussoftware.bibliotech.red;

import com.nexussoftware.bibliotech.dominio.Material;
import com.nexussoftware.bibliotech.excepcion.BiblioTechException;
import com.nexussoftware.bibliotech.servicio.CatalogoConcurrente;
import com.nexussoftware.bibliotech.red.ClienteMetadatos.Metadatos;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Sincroniza las portadas del catalogo de BiblioTech con el servicio
 * externo de metadatos. Secuencial y respetuoso con el servicio.
 */
public class SincronizadorPortadas {

    private static final Logger LOG =
            Logger.getLogger(SincronizadorPortadas.class.getName());

    private static final long MAXIMO_PORTADA = 5L * 1024 * 1024;
    private static final int PETICIONES_POR_SEGUNDO = 2;
    private static final int LIMITE_CONEXION_MS = 5_000;
    private static final int LIMITE_LECTURA_MS = 30_000;
    private static final String AGENTE = "BiblioTech/1.0";

    private final CatalogoConcurrente catalogo;
    private final ClienteMetadatos metadatos;
    private final Path directorio;

    /** Instante del ultimo acceso a la red, para el limitador de ritmo. */
    private long ultimaPeticion = 0;

    private int descargadas = 0;
    private int alDia = 0;
    private int saltadas = 0;
    private int fallidas = 0;
    private long bytesTotales = 0;

    public SincronizadorPortadas(CatalogoConcurrente catalogo,
                                 ClienteMetadatos metadatos, Path directorio) {
        this.catalogo = catalogo;
        this.metadatos = metadatos;
        this.directorio = directorio;
    }

    public void sincronizar() throws IOException {
        Files.createDirectories(directorio);
        long inicio = System.currentTimeMillis();

        List<Material> materiales = catalogo.todos();
        System.out.println("Sincronizando portadas de " + materiales.size()
                + " materiales...\n");

        // SECUENCIAL a proposito. Hacerlo en PARALELO -que reduciria el
        // tiempo total drasticamente, porque casi todo es espera de red-
        // requiere el HttpClient asincrono con sendAsync y allOf, y eso
        // es exactamente lo que se hace en 09-06.
        for (Material material : materiales) {
            procesar(material);
        }

        informe(System.currentTimeMillis() - inicio);
    }

    private void procesar(Material material) {
        String isbn = material.getIsbn();
        Path destino = directorio.resolve(isbn + ".jpg");

        try {
            limitarRitmo();
            Metadatos m = metadatos.consultar(isbn);

            if (m == null || m.urlPortada() == null || m.urlPortada().isBlank()) {
                System.out.printf("  %-18s SALTADA (sin portada en el servicio)%n", isbn);
                saltadas++;
                return;
            }

            limitarRitmo();
            long bytes = descargarSiHaCambiado(m.urlPortada(), destino, isbn);

            if (bytes < 0) {
                alDia++;            // 304: ya la teniamos actualizada
            } else if (bytes == 0) {
                saltadas++;         // rechazada por tipo o tamano
            } else {
                descargadas++;
                bytesTotales += bytes;
            }

        } catch (BiblioTechException | IOException e) {
            fallidas++;
            System.out.printf("  %-18s FALLO: %s%n", isbn, e.getMessage());
            LOG.log(Level.FINE, "Fallo sincronizando " + isbn, e);
        }
    }

    /**
     * Descarga la portada si ha cambiado.
     * @return bytes descargados; -1 si el servidor devolvio 304; 0 si se rechazo.
     */
    private long descargarSiHaCambiado(String urlTexto, Path destino, String isbn)
            throws IOException, BiblioTechException {

        URL url = URI.create(urlTexto).toURL();
        if (!url.getProtocol().startsWith("http")) {
            throw new BiblioTechException("Esquema no permitido: " + url.getProtocol());
        }

        // --- Fase 1: HEAD para comprobar tipo y tamano ---
        HttpURLConnection cabeza = (HttpURLConnection) url.openConnection();
        try {
            cabeza.setRequestMethod("HEAD");
            cabeza.setRequestProperty("User-Agent", AGENTE);
            cabeza.setConnectTimeout(LIMITE_CONEXION_MS);
            cabeza.setReadTimeout(LIMITE_LECTURA_MS);

            int codigo = cabeza.getResponseCode();
            if (codigo == HttpURLConnection.HTTP_OK) {
                String tipo = cabeza.getContentType();
                long longitud = cabeza.getContentLengthLong();

                if (tipo != null && !tipo.startsWith("image/")) {
                    System.out.printf("  %-18s SALTADA (no es imagen: %s)%n", isbn, tipo);
                    return 0;
                }
                if (longitud > MAXIMO_PORTADA) {
                    System.out.printf("  %-18s SALTADA (%d bytes, supera el limite)%n",
                            isbn, longitud);
                    return 0;
                }
            }
            // Un HEAD que falla no impide intentar el GET: muchos
            // servidores no lo implementan bien.
        } finally {
            cabeza.disconnect();
        }

        // --- Fase 2: GET condicional ---
        HttpURLConnection conexion = (HttpURLConnection) url.openConnection();
        Path temporal = null;
        try {
            conexion.setRequestMethod("GET");
            conexion.setRequestProperty("User-Agent", AGENTE);
            conexion.setRequestProperty("Accept", "image/jpeg, image/png, image/*");
            conexion.setConnectTimeout(LIMITE_CONEXION_MS);
            conexion.setReadTimeout(LIMITE_LECTURA_MS);

            // DESCARGA CONDICIONAL: si ya tenemos el fichero, pedimos al
            // servidor que solo nos lo mande si ha cambiado desde entonces.
            // Un 304 ahorra la transferencia entera.
            if (Files.exists(destino)) {
                long modificado = Files.getLastModifiedTime(destino).toMillis();
                conexion.setRequestProperty("If-Modified-Since",
                        fechaHttp(new Date(modificado)));
            }

            int codigo = conexion.getResponseCode();

            if (codigo == HttpURLConnection.HTTP_NOT_MODIFIED) {   // 304
                System.out.printf("  %-18s AL DIA (304, no ha cambiado)%n", isbn);
                return -1;
            }
            if (codigo != HttpURLConnection.HTTP_OK) {
                throw new IOException("HTTP " + codigo + " descargando la portada");
            }

            // Descarga a TEMPORAL: una interrupcion no deja un JPEG a medias
            // que parece valido hasta que alguien intenta abrirlo.
            temporal = Files.createTempFile(directorio, "portada-", ".tmp");
            long descargados = 0;

            try (InputStream entrada = conexion.getInputStream();
                 OutputStream salida = Files.newOutputStream(temporal)) {
                byte[] bufer = new byte[8192];
                int leidos;
                while ((leidos = entrada.read(bufer)) != -1) {
                    descargados += leidos;
                    // El limite se comprueba TAMBIEN aqui: el Content-Length
                    // del HEAD puede faltar o mentir.
                    if (descargados > MAXIMO_PORTADA) {
                        throw new IOException("La portada supera el limite descargando");
                    }
                    salida.write(bufer, 0, leidos);     // nunca bufer.length
                }
            }

            Files.move(temporal, destino,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
            temporal = null;

            System.out.printf("  %-18s DESCARGADA (%d bytes)%n", isbn, descargados);
            return descargados;

        } finally {
            conexion.disconnect();
            if (temporal != null) {
                try {
                    Files.deleteIfExists(temporal);
                } catch (IOException e) {
                    LOG.fine("No se pudo borrar el temporal " + temporal);
                }
            }
        }
    }

    /**
     * Formato de fecha HTTP (RFC 7231): "Wed, 05 Aug 2026 09:14:22 GMT".
     *
     * Se usa SimpleDateFormat porque java.time es 10-05. Dos detalles
     * imprescindibles y que casi todo el mundo olvida:
     *   - Locale.US: sin el, los nombres de dia y mes salen en el idioma
     *     del sistema ("mié", "ago") y el servidor no los entiende.
     *   - Zona GMT: el formato lo exige explicitamente.
     * En 10-05 esto se hace con DateTimeFormatter.RFC_1123_DATE_TIME,
     * que es inmutable y seguro para varios hilos; SimpleDateFormat NO lo es.
     */
    private String fechaHttp(Date fecha) {
        SimpleDateFormat formato = new SimpleDateFormat(
                "EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
        formato.setTimeZone(TimeZone.getTimeZone("GMT"));
        return formato.format(fecha);
    }

    /**
     * Limitador de ritmo: no mas de PETICIONES_POR_SEGUNDO al servicio.
     * Ser un buen ciudadano evita que te bloqueen la IP, y evita
     * provocar los 429 que luego habria que gestionar.
     */
    private void limitarRitmo() {
        long intervalo = 1000 / PETICIONES_POR_SEGUNDO;
        long desde = System.currentTimeMillis() - ultimaPeticion;
        if (desde < intervalo) {
            try {
                Thread.sleep(intervalo - desde);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();      // 08-02
            }
        }
        ultimaPeticion = System.currentTimeMillis();
    }

    private void informe(long ms) {
        System.out.println();
        System.out.println("=== SINCRONIZACION DE PORTADAS ===");
        System.out.printf("%-22s %d%n", "Descargadas", descargadas);
        System.out.printf("%-22s %d%n", "Ya al dia (304)", alDia);
        System.out.printf("%-22s %d%n", "Saltadas", saltadas);
        System.out.printf("%-22s %d%n", "Fallidas", fallidas);
        System.out.printf("%-22s %.1f KB%n", "Bytes descargados", bytesTotales / 1024.0);
        System.out.printf("%-22s %.1f s%n", "Tiempo total", ms / 1000.0);
        System.out.printf("%-22s %s%n", "Directorio", directorio.toAbsolutePath());
    }
}

Salida típica:

Sincronizando portadas de 5 materiales...

  978-0000000001     DESCARGADA (48213 bytes)
  978-0000000002     AL DIA (304, no ha cambiado)
  978-0000000003     DESCARGADA (39104 bytes)
  978-0000000004     SALTADA (sin portada en el servicio)
  978-0000000005     FALLO: HTTP 404 descargando la portada

=== SINCRONIZACION DE PORTADAS ===
Descargadas            2
Ya al dia (304)        1
Saltadas               1
Fallidas               1
Bytes descargados      85.3 KB
Tiempo total           5.4 s

Comentarios. Cuatro puntos.

La descarga condicional con If-Modified-Since es la optimización más rentable de HTTP y casi nadie la usa. Un 304 tiene cuerpo vacío: ahorra la transferencia completa a cambio de un viaje de red. En un catálogo de mil libros con portadas de 50 KB, sincronizar sin condicional mueve 50 MB cada vez; con condicional, unos pocos kilobytes salvo que algo haya cambiado de verdad.

El formato de fecha HTTP tiene dos trampas y las dos están en el código. Sin Locale.US, un sistema en español genera mié, 05 ago 2026 y el servidor lo ignora en silencio, con lo que la condicional deja de funcionar sin que nadie se entere. Y sin setTimeZone("GMT"), la fecha sale en hora local y el servidor la interpreta como GMT, con un desfase de una o dos horas. Es exactamente el tipo de problema que java.time resuelve de raíz en 10-05.

El limitador de ritmo es cortesía y prudencia a la vez. Un cliente que dispara mil peticiones en dos segundos acaba con la IP bloqueada, y mientras tanto provoca los 429 que después habría que gestionar con reintentos. Dos por segundo es lento pero sostenible.

Y el TODO implícito que apunta a la lección siguiente. Sincronizar cinco portadas tarda 5,4 segundos, y prácticamente todo ese tiempo es espera de red: la CPU está parada. Mil portadas tardarían casi veinte minutos. Como las descargas son independientes entre sí, hacerlas en paralelo reduciría el tiempo casi por el factor de paralelismo — y eso es exactamente lo que harás en 09-06 con sendAsync y allOf. Con HttpURLConnection habría que montar el pool y las tareas a mano; con la API moderna, es una cadena de tres llamadas.

Conclusión

Has subido un nivel: has dejado de inventar protocolos y has aprendido a hablar el que entiende todo el mundo.

Sabes descomponer una URL en sus seis partes —esquema, host, puerto, ruta, consulta y fragmento— con los detalles que muerden: el fragmento nunca se envía al servidor y getPort() devuelve -1 cuando no viene explícito. Y conoces la diferencia entre URL y URI y la regla que se deriva de ella: usa URI para representar, manipular y comparar, y convierte a URL solo para conectar, porque URL.equals() hace resolución DNS y convierte una simple comparación en una operación de red que bloquea.

Manejas la codificación de parámetros y sabes por qué es imprescindible: sin ella un espacio rompe la petición, un acento llega corrupto y un & en un valor inyecta parámetros que el servidor interpreta como propios. Con la distinción que casi nadie conoce: URLEncoder codifica el espacio como +, lo cual es correcto en un valor de consulta y erróneo en un segmento de ruta, donde hay que usar el constructor multiargumento de URI.

Y sobre todo entiendes HTTP, no lo usas a ciegas. Sabes que una petición es una línea de método, ruta y versión, unas cabeceras, una línea vacía obligatoria y un cuerpo opcional; que el delimitador es \r\n y no \n; que la cabecera Host es obligatoria porque es la que permite el alojamiento virtual. Sabes que la respuesta tiene la misma forma con una línea de estado, y cómo se marca el fin del cuerpo: Content-Length, Transfer-Encoding: chunked o el cierre de la conexión — las tres técnicas del problema del delimitador de 09-01, todas juntas en el mismo protocolo. Y lo has visto byte a byte haciendo tú de servidor HTTP con nc, exactamente como hiciste de servidor BTCP en 09-02: HTTP es texto sobre un socket TCP, y eso es todo.

Conoces los métodos y las dos propiedades que gobiernan su uso: seguro —no modifica nada, se puede cachear y precargar— e idempotente —repetirlo da el mismo resultado—, que es la propiedad que decide si puedes reintentar tras un tiempo agotado. GET, PUT y DELETE sí; POST y PATCH no, porque un reintento puede duplicar la operación. Y conoces los códigos de estado por familias, con la regla que aplicaste en tu propio BTCP/1: el primer dígito decide sin leer el texto. Con la lista de cuáles merecen reintento —429 respetando Retry-After, y 502, 503, 504— y cuáles no.

Sabes usar HttpURLConnection correctamente, que no es poco: openConnection() no conecta —lo hace getResponseCode(), y por eso toda la configuración va antes—; tiempos límite obligatorios de conexión y de lectura, ambos con valor por defecto infinito; getErrorStream() con 4xx y 5xx, porque getInputStream() lanza y te quita el cuerpo del error que explica el problema; setDoOutput(true) para enviar cuerpo, con la trampa de que cambia el método a POST sin avisar y con el Content-Length medido en bytes, no caracteres; redirecciones automáticas que no saltan entre http y https; y gzip transparente salvo que toques Accept-Encoding, en cuyo caso te toca descomprimir.

Y sabes lo que hay que saber de HTTPS: que funciona solo, que los errores de certificado tienen causas identificables, y la regla que no se negocia: nunca desactives la validación de certificados, porque convierte HTTPS en HTTP con pasos extra y abre la puerta a un intermediario (12-07).

BiblioTech ha empezado a hablar con el mundo exterior. ClienteMetadatos consulta el servicio de Nexus Software por ISBN y descarga portadas a disco combinando HTTP con el NIO.2 del módulo 7: validación de esquema para que una URL file:// no le haga leer ficheros locales, comprobación del tipo y del tamaño antes y durante la descarga porque el Content-Length puede mentir, escritura a temporal y movimiento atómico para que una interrupción no deje un JPEG a medias, lectura acotada con readNBytes en vez de readAllBytes, y traducción de cada fallo a BiblioTechException distinguiendo transitorio de permanente. Más ConsultaUrl, InspectorHttp, ClienteHttpResistente con su política de reintentos y su jitter contra el rebaño atronador, y SincronizadorPortadas con su descarga condicional y su limitador de ritmo.

Y has visto, con honestidad, el apaño del JSON: extraer campos buscando subcadenas funciona con la respuesta concreta de este servicio y se rompe con escapes, anidamiento, arrays o un cambio en el orden de los campos. Está señalado como lo que es —un apaño didáctico— porque hacerlo bien requiere Jackson, y eso es 11-07.

Con la valoración final que toca: HttpURLConnection es verbosa, con configuración por efectos secundarios, sin tiempo límite total, sin asincronía, solo HTTP/1.1 y difícil de probar. No la usarías para código nuevo. Pero está en toda la biblioteca estándar, la encontrarás en código heredado, y —lo importante— te ha enseñado HTTP con las manos. Lo difícil de HTTP nunca fue la API.

En la próxima lección, El Cliente HTTP Moderno, llega la recompensa. java.net.http de Java 11 con sus tres piezas —HttpClient, HttpRequest, HttpResponse—, inmutables y con constructores fluidos, un cliente que se crea una vez y se reutiliza con su pool de conexiones interno, tiempos límite totales de verdad, HTTP/2 con multiplexación, y HttpResponse<T> con manejadores de cuerpo que te dan texto, líneas, un flujo o directamente un fichero. Y sobre todo, el momento en que dos módulos se encuentran: sendAsync devuelve un CompletableFuture<HttpResponse<String>>, y todo lo que aprendiste en 08-07 —thenApply, thenCompose, exceptionally, orTimeout, allOf— se aplica tal cual para consultar los metadatos de varios ISBN en paralelo y componer un informe sin bloquear un solo hilo. Con el problema de rendimiento que dejó abierto el sincronizador de portadas resuelto en tres llamadas. Es la lección de cierre del módulo 9.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados