Fins ara has treballat al nivell del transport. Has obert sockets, has dissenyat BTCP/1 i BTDP/1 des de zero, has decidit on acaba cada missatge i quins codis retorna el servidor. Això està molt bé per entendre com funcionen les coses, però té un problema evident: ningú més no parla els teus protocols. BTCP/1 només l'entén el client que vas escriure tu.

Aquesta lliçó puja un nivell. Deixarem d'inventar protocols i farem servir el que ja domina el planeta: HTTP. Tot servei extern amb què BiblioTech pugui voler parlar —un catàleg de metadades per ISBN, un servei de portades, una passarel·la de correu, qualsevol API— parla HTTP. I la bona notícia és que HTTP no és màgia: és exactament el que viatja pel socket de 09-02, text pla amb un format acordat, delimitat per línies. El veuràs escrit byte a byte.

Java porta dues API per parlar HTTP. L'antiga, HttpURLConnection, de 1996, és la que estudiem avui. La moderna, HttpClient de Java 11, és la de 09-06. I convé dir-ho d'entrada amb honestedat: HttpURLConnection és una API vella, verbosa i plena de paranys. No la faries servir per escriure codi nou. Però és a tota la biblioteca estàndard, apareix en muntanyes de codi heretat, i els seus conceptes —mètodes, capçaleres, codis d'estat, fluxos d'entrada i d'error— són els mateixos que necessitaràs a l'API moderna. Aprendre-la és entendre HTTP amb les mans.

En acabar, BiblioTech consultarà un servei extern de metadades per ISBN i es descarregarà la portada d'un llibre a disc.

Contingut

  1. Anatomia d'una URL
  2. URL davant d'URI
  3. Codificació de paràmetres amb URLEncoder
  4. HTTP a fons: la petició
  5. HTTP a fons: la resposta
  6. Un intercanvi real, byte a byte
  7. Mètodes HTTP
  8. Codis d'estat
  9. Capçaleres clau
  10. El cas simple: URL.openStream()
  11. HttpURLConnection: el control real
  12. Temps límit: mai sense ells
  13. getInputStream davant de getErrorStream
  14. Enviar un cos: POST amb setDoOutput
  15. Redireccions
  16. Compressió i HTTPS
  17. BiblioTech: client de metadades i descàrrega de portades
  18. La valoració honesta d'aquesta API
  19. Errors Comuns i Consells
  20. Exercicis

  1. Anatomia d'una URL

Una URL (localitzador uniforme de recursos) diu on és alguna cosa i com arribar-hi. Té sis parts, i convé saber anomenar-les totes.

 https://api.nexussoftware.com:8443/v1/llibres/cercar?isbn=978-0000000001&format=json#resum
 \___/   \____________________/\__/\_________________/\____________________________/\_____/
   |               |            |          |                      |                    |
esquema          host         port        cami                consulta             fragment
Part Exemple Notes
Esquema https El protocol. Determina el port per defecte
Autoritat api.nexussoftware.com:8443 Host i port; pot incloure usuari:clau@ (obsolet i insegur)
Host api.nexussoftware.com Nom o IP. Es resol per DNS (09-01)
Port 8443 Si s'omet, el de l'esquema: 80 per a http, 443 per a https
Camí /v1/llibres/cercar Quin recurs es demana
Consulta isbn=978-...&format=json Paràmetres, clau=valor separats per &
Fragment resum Mai no s'envia al servidor. És per al client

Aquest últim punt sorprèn molta gent: el fragment (#alguna-cosa) és purament local. El navegador el fa servir per desplaçar-se a una secció de la pàgina; el servidor no el veu mai. Si intentes fer servir el fragment per passar informació a un servei, no hi arribarà.

En Java:

import java.net.URL;

URL url = new URL("https://api.nexussoftware.com:8443/v1/llibres/cercar"
        + "?isbn=978-0000000001&format=json#resum");

System.out.println("Protocol  : " + url.getProtocol());   // https
System.out.println("Host      : " + url.getHost());       // api.nexussoftware.com
System.out.println("Port      : " + url.getPort());       // 8443
System.out.println("Per defecte: " + url.getDefaultPort()); // 443
System.out.println("Cami      : " + url.getPath());       // /v1/llibres/cercar
System.out.println("Consulta  : " + url.getQuery());      // isbn=978-...&format=json
System.out.println("Fragment  : " + url.getRef());        // resum
System.out.println("Fitxer    : " + url.getFile());       // cami + ? + consulta

Compte amb getPort(). Retorna -1 si el port no apareix explícitament a la URL, no el port per defecte. Per obtenir el port efectiu cal combinar-lo amb getDefaultPort():

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

Oblidar-ho produeix el clàssic intent de connectar al port -1.

  1. URL davant d'URI

Java té dues classes per a això i la diferència importa.

java.net.URL java.net.URI
Què representa Un recurs al qual es pot accedir Un identificador, sense més
Sap obrir connexions (openConnection, openStream) No
Valida la sintaxi Poc , estrictament
Necessita conèixer l'esquema : new URL("foo://x") llança excepció No: qualsevol esquema val
equals() Fa resolució DNS (!) Comparació de text
Normalitza camins (.., .) No Sí, amb normalize()
Des de quina versió 1.0 1.4

El punt que més mossega és l'equals() d'URL:

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

// Aquesta comparacio FA UNA CONSULTA DNS i pot trigar segons.
// I retorna true si tots dos noms resolen a la mateixa adreca.
boolean iguals = a.equals(b);

URL.equals() i URL.hashCode() resolen el host per DNS. Això significa que:

  • Comparar dues URL pot bloquejar durant segons.
  • Ficar URL en un HashSet o com a clau d'un HashMap provoca consultes DNS en inserir i en cercar.
  • Sense xarxa, el comportament canvia.

És un error de disseny reconegut, i la regla pràctica és senzilla: fes servir URI per representar, manipular i comparar; converteix a URL només en el moment d'obrir la connexió.

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

// 1. Construir i manipular amb URI: validacio estricta, sense DNS.
URI uri = new URI("https", "api.nexussoftware.com", "/v1/llibres/cercar",
                  "isbn=978-0000000001", null);
//                 esquema  host          cami        consulta        fragment

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

// 2. Convertir a URL nomes per connectar.
URL url = uri.toURL();

URI té a més utilitats que URL no té:

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

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

Nota. A Java 20 es van marcar com a obsolets els constructors d'URL, precisament per empènyer cap a URI.create(...).toURL(). Si compiles amb una versió recent i veus avisos d'obsolescència sobre new URL(...), aquesta és la raó, i la solució és la que ja recomanem.

  1. Codificació de paràmetres amb URLEncoder

Una URL només admet un conjunt restringit de caràcters. Els espais, els accents, els signes &, =, ? i #, i qualsevol caràcter no ASCII s'han de codificar en la forma %XX, on XX és el valor hexadecimal del byte.

Sense codificar, les coses fallen de formes creatives:

Cercar el titol "Java Eficaç":

  MALAMENT:  /cercar?titol=Java Eficaç
        -> L'espai trenca la peticio HTTP (el servidor creu que el cami
           acaba en "Java" i que "Eficaç" es la versio del protocol).
        -> La ç, sense codificar, arriba com a bytes que el servidor pot
           interpretar en una altra codificacio.

  BE: /cercar?titol=Java+Efica%C3%A7
        -> L'espai es +, i la ç son els seus dos bytes UTF-8: C3 A7.

I el cas realment perillós: un valor que contingui & o = injecta paràmetres.

  Cercar autor = "Bloch & Gamma"

  MALAMENT:  /cercar?autor=Bloch & Gamma&admin=true
        -> El servidor veu DOS parametres: autor="Bloch " i " Gamma"
        -> I si l'atacant escriu "&admin=true", injecta un parametre.

  BE: /cercar?autor=Bloch+%26+Gamma

En Java:

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

String titol = "Java Eficaç";
String codificat = URLEncoder.encode(titol, StandardCharsets.UTF_8);
System.out.println(codificat);      // Java+Efica%C3%A7

String tornada = URLDecoder.decode(codificat, StandardCharsets.UTF_8);
System.out.println(tornada);        // Java Eficaç

El charset és obligatori. Existeix una sobrecàrrega URLEncoder.encode(String) sense charset, obsoleta des de fa dècades, que fa servir la codificació de la plataforma. Amb ella, la mateixa URL surt diferent a Linux i a Windows. Fes servir sempre la versió de dos arguments amb StandardCharsets.UTF_8, que a més és el que espera qualsevol servidor modern.

Un parany important

URLEncoder està pensat per a valors de formulari (application/x-www-form-urlencoded), no per a camins. La seva diferència visible: codifica l'espai com a +, no com a %20.

Context Espai Eina correcta
Valor d'un paràmetre de consulta + (o %20, tots dos vàlids) URLEncoder
Segment del camí %20. Un + al camí és un + literal URI amb constructor multiargument
// MALAMENT: al cami, el + no significa espai.
String cami = "/llibres/" + URLEncoder.encode("Java Eficac", UTF_8);
// -> /llibres/Java+Eficac   (el servidor cercara un llibre amb un + al titol)

// BE: deixar que URI codifiqui el cami correctament.
URI uri = new URI("https", "api.nexussoftware.com",
                  "/llibres/Java Eficac", null, null);
// -> https://api.nexussoftware.com/llibres/Java%20Eficac

El constructor multiargument d'URI codifica cada component segons les seves regles. És la forma correcta i la que farem servir a BiblioTech.

Un ajudant per construir cadenes de consulta:

package com.nexussoftware.bibliotech.xarxa;

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

/**
 * Constructor de cadenes de consulta amb codificacio correcta.
 * LinkedHashMap perque l'ordre sigui predictible (util en depurar i en cachejar).
 */
public class ConsultaUrl {

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

    public ConsultaUrl amb(String clau, String valor) {
        if (valor != null) {
            parametres.put(clau, valor);
        }
        return this;    // encadenable
    }

    public ConsultaUrl amb(String clau, int valor) {
        parametres.put(clau, String.valueOf(valor));
        return this;
    }

    /** Retorna "a=1&b=2" amb tot codificat, o "" si no hi ha parametres. */
    public String construir() {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> e : parametres.entrySet()) {
            if (sb.length() > 0) {
                sb.append('&');
            }
            // Es codifiquen TANT la clau COM el valor: una clau amb
            // caracters estranys trenca la peticio 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()
        .amb("titol", "Java Eficaç")
        .amb("autor", "Bloch & Gamma")
        .amb("max", 10)
        .construir();
// titol=Java+Efica%C3%A7&autor=Bloch+%26+Gamma&max=10

  1. HTTP a fons: la petició

Aquí arriba el moment en què tot encaixa. Una petició HTTP és text pla enviat per un socket TCP, amb el format acordat. Exactament el que saps fer des de 09-02.

GET /v1/llibres/978-0000000001 HTTP/1.1\r\n    <- linia de peticio
Host: api.nexussoftware.com\r\n                 <- capcaleres
Accept: application/json\r\n
User-Agent: BiblioTech/1.0\r\n
Connection: close\r\n
\r\n                                            <- LINIA BUIDA: fi de capcaleres
                                                <- (aqui aniria el cos, si n'hi hagues)

Quatre elements:

  1. Línia de petició: MÈTODE CAMÍ VERSIÓ. Separats per un espai.
  2. Capçaleres: Nom: valor, una per línia. Els noms no distingeixen majúscules.
  3. Línia buida: marca el final de les capçaleres. Obligatòria.
  4. Cos (opcional): les dades, a POST, PUT, PATCH.

Detalls que importen:

  • El delimitador és \r\n, no \n. HTTP ho exigeix estrictament. Si escrius això a mà amb un PrintWriter i println(), a Linux enviaràs només \n i alguns servidors ho rebutjaran. És exactament l'advertiment de 09-02 sobre el separador de línia de la plataforma.
  • La capçalera Host és obligatòria a HTTP/1.1. És la que permet que una sola IP serveixi centenars de llocs diferents (hosting virtual): el servidor decideix quin lloc servir mirant aquella capçalera.
  • La línia buida és imprescindible. Sense ella, el servidor continua esperant capçaleres i la teva petició no es processa mai: el mateix interbloqueig del flush oblidat de 09-02, per una altra causa.

Ho pots escriure a mà ara mateix

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

Això és un client HTTP complet escrit amb nc. I amb el que saps de 09-02, el podries escriure en Java en vint línies. HTTP no és més que un protocol de text sobre TCP, com el BTCP/1 que vas dissenyar; la diferència és que aquest l'entén mig planeta.

  1. HTTP a fons: la resposta

La resposta té la mateixa estructura, amb la primera línia diferent.

HTTP/1.1 200 OK\r\n                             <- linia d'estat
Content-Type: application/json; charset=utf-8\r\n  <- capcaleres
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                                            <- linia buida
{"isbn":"978-0000000001","titol":"Java Eficac","autor":"Bloch","pagines":416}
  1. Línia d'estat: VERSIÓ CODI TEXT. El codi és el que importa; el text és informatiu.
  2. Capçaleres: igual que a la petició.
  3. Línia buida.
  4. Cos: el contingut.

Com sap el client on acaba el cos? És la mateixa pregunta del problema del delimitador de 09-01, i HTTP la resol de tres formes:

Mecanisme Com Quan es fa servir
Content-Length: 87 Longitud prèvia: exactament 87 bytes L'habitual, quan la mida es coneix
Transfer-Encoding: chunked Trossos, cadascun precedit de la seva mida en hexadecimal, i un tros de mida 0 al final Quan la mida no es coneix per endavant (contingut generat)
Tancament de la connexió El cos acaba quan el servidor tanca HTTP/1.0, o Connection: close sense longitud

Les tres tècniques de l'apartat sobre delimitadors de 09-01, totes juntes. HTTP és un cas d'estudi de disseny de protocols, i ara tens el context per apreciar-ho.

  1. Un intercanvi real, byte a byte

Ho veurem de veritat, sense Java pel mig. Arrenca un servidor local que mostri el que li arriba:

Terminal 1:

nc -l 8080

Terminal 2:

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

A la terminal 1 apareix exactament el que curl ha enviat:

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

Ara teclegia tu la resposta a la terminal 1 (recorda la línia buida abans del cos) i prem Ctrl+D:

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

{"isbn":"978-0000000001","titol":"Java Eficac"}

I a la terminal 2, curl mostra l'intercanvi complet:

* Connected to localhost (127.0.0.1) port 8080
> GET /v1/llibres/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","titol":"Java Eficac"}

Acabes de fer de servidor HTTP amb nc, igual que a 09-02 vas fer de servidor BTCP. És el mateix exercici, amb un altre protocol. I deixa claríssima la idea central d'aquesta lliçó: HTTP és text sobre un socket, i tot el que fa HttpURLConnection és formatar aquest text per tu i analitzar la resposta.

sequenceDiagram
    participant C as Client (BiblioTech)
    participant S as Servidor de metadades
    Note over C,S: Connexio TCP: salutacio de tres vies (09-01)
    C->>S: GET /v1/llibres/978-0000000001 HTTP/1.1
    C->>S: Host: api.nexussoftware.com
    C->>S: Accept: application/json
    C->>S: (linia buida)
    Note over S: Processa la peticio
    S-->>C: HTTP/1.1 200 OK
    S-->>C: Content-Type: application/json
    S-->>C: Content-Length: 87
    S-->>C: (linia buida)
    S-->>C: {"isbn":"978-...","titol":"Java Eficac"}
    Note over C,S: Tancament, o reutilitzacio si keep-alive

  1. Mètodes HTTP

Mètode Per a què Segur? Idempotent? Cos?
GET Obtenir un recurs No
HEAD Com GET però només capçaleres No
POST Crear, o enviar dades a processar No No
PUT Reemplaçar un recurs complet No
PATCH Modificar parcialment No No
DELETE Esborrar un recurs No Rar
OPTIONS Què es pot fer amb aquest recurs No

Les dues propietats de la taula tenen conseqüències pràctiques directes:

Segur significa que no modifica res al servidor. Un GET es pot repetir, cachejar i precarregar sense conseqüències. Per això és un error greu fer servir GET per a accions que canvien alguna cosa: un rastrejador web, o la precàrrega del navegador, executarien l'acció sense que ningú l'hagués demanat.

Idempotent significa que repetir-lo produeix el mateix resultat que fer-ho una vegada. I això decideix si pots reintentar després d'un temps esgotat:

  • GET, PUT, DELETE, HEAD: reintentar és segur.
  • POST i PATCH: reintentar pot duplicar l'operació.

Si envies un POST que registra un préstec i s'esgota el temps d'espera, no saps si el servidor l'ha processat o no. Reintentar pot crear dos préstecs. És exactament el mateix raonament que a 09-04 va portar a deixar PRESTAR a TCP en lloc d'UDP, aplicat aquí. La solució professional és la clau d'idempotència: el client genera un identificador únic, l'envia en una capçalera, i el servidor rebutja la segona petició amb la mateixa clau. Ho esmentem a 09-06 en parlar de reintents.

  1. Codis d'estat

El primer dígit indica la família, i això permet decidir sense llegir el text — la mateixa idea que vas aplicar a BTCP/1.

Família Significat Què fer
1xx Informatiu Rar; gairebé mai no el veuràs a la pràctica
2xx Èxit Processar la resposta
3xx Redirecció Anar a un altre lloc (sovint automàtic)
4xx Error del client T'has equivocat tu. No reintentis sense canviar res
5xx Error del servidor S'ha equivocat ell. Reintentar pot tenir sentit

Els que veuràs de veritat:

Codi Nom Quan
200 OK Tot bé
201 Created Recurs creat (resposta típica a un POST)
204 No Content Bé, però no hi ha cos (típic de DELETE)
301 Moved Permanently Ha canviat de lloc per sempre. Actualitza els teus enllaços
302 Found Ha canviat temporalment
304 Not Modified No ha canviat des de la teva última consulta; fes servir la teva memòria cau
400 Bad Request La teva petició està mal formada
401 Unauthorized No t'has autenticat. El nom és enganyós
403 Forbidden T'has autenticat, però no tens permís
404 Not Found El recurs no existeix
405 Method Not Allowed Aquell recurs no admet aquell mètode
409 Conflict Conflicte d'estat (el mateix 409 de BTCP/1)
429 Too Many Requests Límit de peticions superat. Mira Retry-After
500 Internal Server Error Fallada genèrica del servidor
502 Bad Gateway Un intermediari no ha obtingut resposta del servidor real
503 Service Unavailable Saturat o en manteniment. Sol ser temporal
504 Gateway Timeout Un intermediari ha esgotat la seva espera

Quins mereixen reintent (això ho aplicaràs a 09-06):

Codi Reintentar? Motiu
429 , esperant el que digui Retry-After És el que el servidor et demana
502, 503, 504 , amb espera creixent Fallades transitòries d'infraestructura
500 Amb cautela Pot ser una fallada determinista que es repetirà
4xx en general No Reintentar el mateix donarà el mateix

I l'error clàssic número u amb aquesta API, que mereix el seu propi requadre:

Un codi 4xx o 5xx NO llança excepció. Una resposta 404 o 500 és una resposta HTTP perfectament vàlida i correctament lliurada. Des del punt de vista de la xarxa, tot ha anat bé. Cal comprovar getResponseCode() a mà, sempre. Suposar que "si no hi ha hagut excepció, ha anat bé" és la causa d'una quantitat enorme de bugs.

  1. Capçaleres clau

Capçalera Direcció Per a què
Host Petició Obligatòria a HTTP/1.1. Quin lloc es demana
Accept Petició Quins formats accepta el client: application/json
Accept-Encoding Petició Quines compressions accepta: gzip, deflate
Accept-Language Petició Idiomes preferits: ca-ES, ca;q=0.9
User-Agent Petició Qui ets. Posa'n un d'identificatiu, no el de Java per defecte
Authorization Petició Credencials: Bearer <token> o Basic <base64>
Content-Type Ambdues Quin format té el cos
Content-Length Ambdues Quants bytes té el cos
Content-Encoding Resposta Com ve comprimit el cos
Location Resposta On anar en una redirecció (3xx)
Retry-After Resposta Quants segons esperar abans de reintentar (429, 503)
Cache-Control Ambdues Política de memòria cau
ETag Resposta Identificador de versió, per a consultes condicionals
Set-Cookie / Cookie Resposta / Petició Estat de sessió

Dos consells concrets:

Posa un User-Agent identificatiu. Per defecte Java envia una cosa com Java/17.0.9, que molts serveis bloquegen per associar-lo a rastrejadors mal educats. Un BiblioTech/1.0 (+https://nexussoftware.com/bibliotech) t'identifica i dóna a qui administra el servei una forma de contactar si alguna cosa va malament.

Llegeix el Content-Type de la resposta per saber el charset. És l'única forma correcta de descodificar el cos:

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

Si l'ignores i suposes UTF-8, funcionarà el 95 % de les vegades i et donarà caràcters corruptes en el 5 % restant. Escriurem un ajudant que l'extregui.

  1. El cas simple: URL.openStream()

Per descarregar alguna cosa sense més cerimònia:

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/llibres/978-0000000001").toURL();

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

Reconeixeràs la pila: InputStreamReader amb charset explícit sobre un InputStream, i un BufferedReader a sobre. És la mateixa del mòdul 7 i la mateixa de 09-02. Només canvia d'on surt l'InputStream.

Les seves limitacions el descarten per a gairebé tot:

Limitació Conseqüència
Sense temps límit Es pot bloquejar indefinidament. Descalificador absolut
Només GET No serveix per enviar res
No es poden posar capçaleres Ni Accept, ni Authorization, ni User-Agent
No es veu el codi d'estat Un 404 llança FileNotFoundException; un 500, IOException. Sense distingir
No es veuen les capçaleres de resposta No saps el Content-Type ni el charset

Serveix per a un main de prova o per llegir un recurs local. Per a qualsevol altra cosa, HttpURLConnection.

  1. HttpURLConnection: el control real

El flux d'ús té un ordre estricte que cal respectar:

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

Els dos punts que confonen tothom:

openConnection() no connecta. Retorna un objecte de configuració. La connexió real passa a la primera crida que necessita la resposta: getResponseCode(), getInputStream() o getHeaderFields(). Per això tota la configuració s'ha de fer abans d'aquestes crides; després, s'ignora en silenci o llança IllegalStateException.

Cal fer un cast. openConnection() declara retornar URLConnection, i tots els mètodes HTTP són a la subclasse HttpURLConnection.

Un exemple complet i correcte:

package com.nexussoftware.bibliotech.xarxa;

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;

/** Peticio HTTP GET amb HttpURLConnection, feta correctament. */
public class ExempleGet {

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

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

        // 1. openConnection() NO connecta: retorna un objecte de configuracio.
        HttpURLConnection connexio = (HttpURLConnection) url.openConnection();

        try {
            // 2. Configuracio. TOT aixo ha d'anar ABANS de getResponseCode().
            connexio.setRequestMethod("GET");
            connexio.setRequestProperty("Accept", "application/json");
            connexio.setRequestProperty("User-Agent", "BiblioTech/1.0");
            connexio.setRequestProperty("Accept-Charset", "UTF-8");

            // MAI sense temps limit. Tots dos, i son diferents (09-02).
            connexio.setConnectTimeout(5_000);      // establir la connexio
            connexio.setReadTimeout(10_000);        // esperar dades

            // 3. AQUI es connecta de veritat i es llegeix la resposta.
            int codi = connexio.getResponseCode();
            String missatge = connexio.getResponseMessage();
            System.out.println("Estat: " + codi + " " + missatge);

            // 4. Capcaleres de resposta.
            System.out.println("Content-Type  : " + connexio.getContentType());
            System.out.println("Content-Length: " + connexio.getContentLengthLong());

            for (Map.Entry<String, List<String>> e : connexio.getHeaderFields().entrySet()) {
                // COMPTE: la clau null conte la LINIA D'ESTAT.
                // Es una raresa de l'API que sorpren la primera vegada.
                String nom = e.getKey() == null ? "(linia d'estat)" : e.getKey();
                System.out.println("  " + nom + ": " + e.getValue());
            }

            // 5. El cos: getInputStream si ha anat be, getErrorStream si no.
            //    AQUESTA distincio es l'error classic numero dos.
            InputStream cos = (codi >= 200 && codi < 400)
                    ? connexio.getInputStream()
                    : connexio.getErrorStream();

            if (cos == null) {
                System.out.println("(sense cos)");
                return;
            }

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

        } finally {
            // 6. disconnect() allibera la connexio (o la torna al pool intern).
            connexio.disconnect();
        }
    }

    /**
     * Extreu el charset del Content-Type. Suposar UTF-8 funciona
     * gairebe sempre i falla justament quan pitjor va.
     */
    static Charset charsetDe(String contentType) {
        if (contentType != null) {
            for (String part : contentType.split(";")) {
                String p = part.strip();
                if (p.toLowerCase().startsWith("charset=")) {
                    String nom = p.substring("charset=".length())
                            .replace("\"", "").strip();
                    try {
                        return Charset.forName(nom);
                    } catch (Exception e) {
                        LOG.warning("Charset desconegut: " + nom + "; es fa servir UTF-8");
                    }
                }
            }
        }
        return StandardCharsets.UTF_8;      // reserva raonable
    }
}

Fixa't en la raresa de getHeaderFields(): l'entrada amb clau null conté la línia d'estat. És un detall de l'API que apareix tan bon punt la recorres i que desconcerta si no t'ho esperes.

Sobre disconnect(): el seu nom enganya. No tanca necessàriament el socket, perquè Java manté un pool de connexions persistents internament. El que fa és indicar que has acabat amb aquella connexió, i si has consumit el cos sencer, la connexió es pot reutilitzar. Si abandones sense llegir el cos, es tanca de veritat i perds la reutilització. Per això convé llegir sempre la resposta completa, fins i tot quan no t'interessi.

  1. Temps límit: mai sense ells

Ja ho has vist a 09-02 i a 09-03, però amb HTTP hi ha un agreujant: estàs parlant amb un servei de tercers que no controles, en una xarxa que no controles.

connexio.setConnectTimeout(5_000);      // establir la connexio TCP
connexio.setReadTimeout(10_000);        // esperar dades, per operacio
Temps límit Cobreix Sense ell
setConnectTimeout La salutació de tres vies Pot trigar més d'un minut a rendir-se
setReadTimeout Cada operació de lectura Pot esperar indefinidament

El valor per defecte de tots dos és 0, que significa infinit. Un servei extern que accepta la connexió i no respon mai deixa el teu fil bloquejat per sempre. Si això passa en un fil d'un pool acotat, uns pocs casos esgoten el pool i la teva aplicació deixa de funcionar sense ni un sol error al log. És exactament l'escenari que ja coneixes de 09-03, amb la diferència que aquí depèn d'un tercer.

I una limitació que cal conèixer: setReadTimeout és per operació, no total. Un servidor que enviï un byte cada nou segons manté la teva lectura viva indefinidament sense esgotar mai un termini de deu segons. HttpURLConnection no té temps límit total de petició; HttpClient de 09-06 sí que el té amb HttpRequest.timeout(), i és una de les seves millores.

Valors raonables:

Tipus de servei Connexió Lectura
Intern, mateixa xarxa 1-2 s 3-5 s
Extern, API ràpida 3-5 s 10 s
Extern, operació pesada 5 s 30-60 s
Descàrrega de fitxer gran 5 s 30 s (per operació, no total)

  1. getInputStream davant de getErrorStream

El segon error clàssic, i dels que més temps fan perdre.

// CODI TRENCAT.
int codi = connexio.getResponseCode();
InputStream entrada = connexio.getInputStream();   // <-- LLANCA amb un 404

Amb un codi 4xx o 5xx, getInputStream() llança IOException (FileNotFoundException per al 404). I el greu és el que es perd: el cos de l'error, que gairebé sempre conté l'explicació de què has fet malament.

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

{"error":"isbn_invalid","missatge":"L'ISBN ha de tenir 13 digits","camp":"isbn"}

Aquest JSON és justament el que necessites per depurar, i getInputStream() t'impedeix llegir-lo. La forma correcta:

int codi = connexio.getResponseCode();

InputStream cos = (codi >= 200 && codi < 400)
        ? connexio.getInputStream()
        : connexio.getErrorStream();

Amb dos matisos:

  • getErrorStream() pot retornar null si el servidor no ha enviat cos d'error. Cal comprovar-ho.
  • getErrorStream() no llança excepció, ni tan sols quan no hi ha res. Retorna null i prou.

Un ajudant que resol el patró d'una vegada per totes:

/**
 * Llegeix el cos de la resposta, vingui pel flux normal o pel d'error.
 * Retorna cadena buida si no hi ha cos.
 */
static String llegirCos(HttpURLConnection connexio, int codi) throws IOException {
    InputStream entrada = (codi >= 200 && codi < 400)
            ? connexio.getInputStream()
            : connexio.getErrorStream();

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

    Charset charset = charsetDe(connexio.getContentType());
    // readAllBytes es comode pero SENSE LIMIT: una resposta gegant
    // esgota la memoria. S'acota, com a 09-03.
    try (InputStream flux = entrada) {
        byte[] bytes = flux.readNBytes(MAXIM_COS);
        if (bytes.length == MAXIM_COS) {
            LOG.warning("Resposta truncada a " + MAXIM_COS + " bytes");
        }
        return new String(bytes, charset);
    }
}

  1. Enviar un cos: POST amb setDoOutput

Per enviar dades cal activar explícitament la sortida:

package com.nexussoftware.bibliotech.xarxa;

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 amb cos JSON fent servir HttpURLConnection. */
public class ExemplePost {

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

        try {
            connexio.setRequestMethod("POST");

            // setDoOutput(true) es el que habilita getOutputStream().
            // COMPTE: a mes canvia el metode per defecte a POST, aixi que
            // si fas setDoOutput(true) despres d'un setRequestMethod("GET"),
            // la peticio surt com a POST. Parany classic d'aquesta API.
            connexio.setDoOutput(true);

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

            // El cos, construit a ma. Escapar les cometes i les
            // barres es imprescindible; fer-ho BE requereix una llibreria
            // de JSON, i aixo es 11-07.
            String json = "{\"isbn\":\"978-0000000001\",\"empleat\":\"Marta Ruiz\"}";
            byte[] cos = json.getBytes(StandardCharsets.UTF_8);

            // Content-Length en BYTES, no en caracters: amb accents
            // no coincideixen, i un valor equivocat corromp la peticio.
            connexio.setFixedLengthStreamingMode(cos.length);

            try (OutputStream sortida = connexio.getOutputStream()) {
                sortida.write(cos);
                sortida.flush();    // el buidatge de sempre
            }

            int codi = connexio.getResponseCode();
            System.out.println("Estat: " + codi);
            System.out.println("Cos: " + llegirCos(connexio, codi));

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

Quatre punts que mereixen atenció:

setDoOutput(true) canvia el mètode a POST implícitament. Si escrius setRequestMethod("GET") i després setDoOutput(true), la petició surt com a POST. És un dels paranys més citats d'aquesta API.

Content-Length es mesura en bytes. "Eficaç".length() són 6 caràcters però 7 bytes en UTF-8. Posar la longitud en caràcters corromp la petició.

Els modes d'enviament del cos:

Mode Quan Efecte
setFixedLengthStreamingMode(n) Es coneix la mida Envia amb Content-Length. El preferible
setChunkedStreamingMode(n) No es coneix (contingut generat) Envia amb Transfer-Encoding: chunked
Cap Per omissió Guarda tot el cos en memòria abans d'enviar. Dolent per a fitxers grans

Escapar el JSON a mà és un apedaçament. L'exemple funciona perquè els valors són simples, però un títol amb cometes o una barra invertida trencaria el JSON. Construir i analitzar JSON correctament requereix una biblioteca —Jackson— i això és 11-07. Aquí ho fem a mà i essent conscients de la limitació.

Formulari en lloc de JSON

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

// Aqui SI que es correcte fer servir URLEncoder: es exactament el seu format.
String cos = new ConsultaUrl()
        .amb("isbn", "978-0000000001")
        .amb("empleat", "Marta Ruiz")
        .construir();
// isbn=978-0000000001&empleat=Marta+Ruiz

  1. Redireccions

Quan el servidor respon 301, 302, 303, 307 o 308, la capçalera Location indica on anar.

HttpURLConnection segueix les redireccions automàticament per defecte, cosa que és còmoda i de vegades indesitjable.

// Global, per a tota la JVM. Evita-ho: afecta codi que no es teu.
HttpURLConnection.setFollowRedirects(false);

// Per instancia. Aquest es el que has de fer servir.
connexio.setInstanceFollowRedirects(false);

Casos en què convé desactivar-les:

  • Vols saber que hi ha hagut redirecció, per exemple per actualitzar una URL desada després d'un 301.
  • Necessites controlar el nombre de salts, per no caure en un bucle infinit.
  • Envies credencials: en seguir automàticament, la capçalera Authorization es podria enviar a un host diferent del previst. És un risc de filtració real.

I una limitació important: HttpURLConnection no segueix redireccions entre http i https. Si demanes http://exemple.com i respon 301 cap a https://exemple.com, la biblioteca no segueix el salt i et retorna el 301 sense cos útil. És una font inesgotable de confusió, perquè curl i els navegadors sí que ho fan.

/** Segueix redireccions a ma, amb limit i control d'esquema. */
static HttpURLConnection seguirRedireccions(URL url, int maximSalts)
        throws IOException {
    URL actual = url;
    for (int salt = 0; salt <= maximSalts; salt++) {
        HttpURLConnection connexio = (HttpURLConnection) actual.openConnection();
        connexio.setInstanceFollowRedirects(false);     // les gestionem nosaltres
        connexio.setConnectTimeout(5_000);
        connexio.setReadTimeout(10_000);

        int codi = connexio.getResponseCode();
        if (codi < 300 || codi >= 400) {
            return connexio;        // no es redireccio: hi hem arribat
        }

        String desti = connexio.getHeaderField("Location");
        connexio.disconnect();

        if (desti == null) {
            throw new IOException("Redireccio " + codi + " sense capcalera Location");
        }
        // resolve() gestiona les Location relatives ("/nou/cami"),
        // que son perfectament legals i sorprenen si no s'esperen.
        actual = actual.toURI().resolve(desti).toURL();
        LOG.info("Redireccio " + codi + " -> " + actual);
    }
    throw new IOException("Massa redireccions (mes de " + maximSalts + ")");
}

  1. Compressió i HTTPS

Compressió amb gzip

Un cos JSON comprimit pot ocupar la cinquena part. HttpURLConnection anuncia gzip per defecte i el descomprimeix sol... però només si tu no toques la capçalera Accept-Encoding. Si la poses a mà, la descompressió automàtica es desactiva i reps bytes comprimits.

// Si poses aixo A MA, HAS de descomprimir tu.
connexio.setRequestProperty("Accept-Encoding", "gzip");

int codi = connexio.getResponseCode();
InputStream entrada = connexio.getInputStream();

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

GZIPInputStream és un decorador més, igual que BufferedInputStream. El mòdul 7 continua rendint.

Recomanació: no toquis Accept-Encoding i deixa que la biblioteca se'n faci càrrec. Només fes-ho si necessites control explícit.

HTTPS

Si la URL comença per https, openConnection() retorna un HttpsURLConnection, subclasse d'HttpURLConnection. No cal fer res més: el xifratge TLS, la validació del certificat i la comprovació del nom de host passen de forma transparent.

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

// ... igual que sempre ...

// Metodes addicionals, si necessites inspeccionar el certificat:
System.out.println("Xifratge   : " + connexio.getCipherSuite());
System.out.println("Certificat : " + connexio.getServerCertificates()[0]);

Errors que veuràs i què signifiquen:

Excepció Causa Solució correcta
SSLHandshakeException: PKIX path building failed El certificat no està signat per una autoritat que Java reconegui (autosignat, o CA interna) Importar el certificat al magatzem de confiança, no desactivar la validació
SSLHandshakeException: No name matching X found El certificat és per a un altre nom de host Fer servir el nom correcte
SSLException: Received fatal alert: protocol_version Versions de TLS incompatibles Actualitzar el JDK o el servidor

No desactivis mai la validació de certificats. Veuràs per internet fragments de codi amb un TrustManager que accepta tot i un HostnameVerifier que retorna true sempre. Això elimina tota la seguretat de TLS: converteix HTTPS en HTTP amb passos extra, i deixa la connexió oberta a un atac d'intermediari. Si tens un certificat intern, la solució és importar-lo al magatzem de confiança (keytool -importcert) o fer servir un SSLContext amb un magatzem propi. La seguretat de xarxa es tracta a fons a 12-07.

  1. BiblioTech: client de metadades i descàrrega de portades

Ara tot junt. Nexus Software té un servei intern de metadades bibliogràfiques, i BiblioTech el consultarà.

El contracte del servei

GET /v1/llibres/{isbn}
  200 -> {"isbn":"...","titol":"...","autor":"...","pagines":416,
          "portada":"https://metadades.nexussoftware.local/portades/978-0000000001.jpg"}
  404 -> {"error":"no_trobat","missatge":"ISBN desconegut"}
  429 -> capcalera Retry-After amb els segons d'espera

GET /portades/{isbn}.jpg
  200 -> imatge JPEG binaria

El client

package com.nexussoftware.bibliotech.xarxa;

import com.nexussoftware.bibliotech.excepcio.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;

/**
 * Client del servei extern de metadades de Nexus Software,
 * amb HttpURLConnection.
 *
 * Mostra l'us correcte de l'API antiga: temps limit, comprovacio
 * explicita del codi d'estat, getErrorStream per als errors, lectura
 * acotada i traduccio a excepcions de domini.
 */
public class ClientMetadades {

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

    private static final int LIMIT_CONNEXIO_MS = 5_000;
    private static final int LIMIT_LECTURA_MS = 10_000;
    /** Cap fitxa de metadades legitima no passa d'aixo. Defensa de memoria. */
    private static final int MAXIM_COS = 256 * 1024;
    /** Cap portada legitima no passa d'aixo. */
    private static final long MAXIMA_PORTADA = 5L * 1024 * 1024;
    private static final String AGENT = "BiblioTech/1.0 (+https://nexussoftware.com)";

    private final String base;

    public ClientMetadades(String base) {
        // Es normalitza per poder concatenar camins sense duplicar barres.
        this.base = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
    }

    /** Metadades d'un llibre tal com les retorna el servei. */
    public record Metadades(String isbn, String titol, String autor,
                            int pagines, String urlPortada) {
    }

    // =================================================================
    // Consulta de metadades
    // =================================================================

    /** Consulta un ISBN. Retorna null si el servei no el coneix (404). */
    public Metadades consultar(String isbn) throws BiblioTechException {
        validarIsbn(isbn);

        HttpURLConnection connexio = null;
        try {
            // L'ISBN va al CAMI, aixi que es codifica amb el constructor
            // multiargument d'URI, no amb URLEncoder (que posaria + per espai).
            URI uri = new URI("http", null, hostDe(base), portDe(base),
                    camiDe(base) + "/v1/llibres/" + isbn, null, null);
            URL url = uri.toURL();

            connexio = (HttpURLConnection) url.openConnection();
            connexio.setRequestMethod("GET");
            connexio.setRequestProperty("Accept", "application/json");
            connexio.setRequestProperty("User-Agent", AGENT);
            connexio.setConnectTimeout(LIMIT_CONNEXIO_MS);
            connexio.setReadTimeout(LIMIT_LECTURA_MS);
            connexio.setInstanceFollowRedirects(true);

            // AQUI es connecta de veritat.
            int codi = connexio.getResponseCode();
            LOG.fine(() -> "GET " + url + " -> " + codi);

            // 404 no es un error del programa: es "no el tinc".
            if (codi == HttpURLConnection.HTTP_NOT_FOUND) {
                consumirITancar(connexio, codi);
                return null;
            }

            // 429: el servei ens demana que baixem el ritme.
            if (codi == 429) {
                String espera = connexio.getHeaderField("Retry-After");
                consumirITancar(connexio, codi);
                throw new BiblioTechException(
                        "El servei de metadades ha limitat les peticions."
                        + (espera == null ? "" : " Reintentar d'aqui a " + espera + " s."));
            }

            if (codi != HttpURLConnection.HTTP_OK) {
                // El cos de l'error gairebe sempre explica que ha passat:
                // llegir-lo es la diferencia entre depurar en dos minuts
                // o en dues hores.
                String detall = llegirCos(connexio, codi);
                LOG.warning("Resposta " + codi + " del servei: " + retallar(detall));
                throw new BiblioTechException(
                        "El servei de metadades ha respost " + codi);
            }

            String cos = llegirCos(connexio, codi);
            return analitzar(cos, isbn);

        } catch (SocketTimeoutException e) {
            // TRANSITORI: mereix reintent amb espera creixent.
            LOG.warning("Temps esgotat consultant metadades de " + isbn);
            throw new BiblioTechException(
                    "El servei de metadades no respon a temps.", e);

        } catch (UnknownHostException e) {
            // PERMANENT: configuracio mal posada.
            LOG.severe("Host de metadades no resoluble: " + base);
            throw new BiblioTechException(
                    "No es troba el servei de metadades.", e);

        } catch (IOException | java.net.URISyntaxException e) {
            LOG.log(Level.WARNING, "Fallada consultant metadades de " + isbn, e);
            throw new BiblioTechException(
                    "Error consultant el servei de metadades.", e);

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

    // =================================================================
    // Descarrega de la portada (binari a disc)
    // =================================================================

    /**
     * Descarrega la portada a un fitxer. Combina HTTP amb NIO.2 del modul 7.
     * Escriptura ATOMICA: primer a un temporal, despres es mou. Aixi una
     * descarrega interrompuda no deixa un JPEG a mitges al cataleg.
     */
    public Path descarregarPortada(String urlPortada, Path desti)
            throws BiblioTechException {

        HttpURLConnection connexio = null;
        Path temporal = null;

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

            // Nomes http i https: sense aixo, una URL "file:///etc/passwd"
            // rebuda del servei ens faria llegir fitxers locals.
            String esquema = url.getProtocol();
            if (!esquema.equals("http") && !esquema.equals("https")) {
                throw new BiblioTechException("Esquema no permes: " + esquema);
            }

            connexio = (HttpURLConnection) url.openConnection();
            connexio.setRequestMethod("GET");
            connexio.setRequestProperty("Accept", "image/jpeg, image/png, image/*");
            connexio.setRequestProperty("User-Agent", AGENT);
            connexio.setConnectTimeout(LIMIT_CONNEXIO_MS);
            connexio.setReadTimeout(30_000);        // una imatge triga mes

            int codi = connexio.getResponseCode();
            if (codi != HttpURLConnection.HTTP_OK) {
                consumirITancar(connexio, codi);
                throw new BiblioTechException(
                        "No s'ha pogut descarregar la portada: HTTP " + codi);
            }

            // Content-Length es ORIENTATIU: pot faltar (-1) o mentir.
            // Es comprova abans I durant la descarrega.
            long anunciat = connexio.getContentLengthLong();
            if (anunciat > MAXIMA_PORTADA) {
                consumirITancar(connexio, codi);
                throw new BiblioTechException(
                        "Portada massa gran: " + anunciat + " bytes");
            }

            String tipus = connexio.getContentType();
            if (tipus != null && !tipus.startsWith("image/")) {
                consumirITancar(connexio, codi);
                throw new BiblioTechException("El recurs no es una imatge: " + tipus);
            }

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

            long descarregats = 0;
            try (InputStream entrada = connexio.getInputStream();
                 OutputStream sortida = Files.newOutputStream(temporal)) {

                byte[] bufer = new byte[8192];
                int llegits;
                while ((llegits = entrada.read(bufer)) != -1) {
                    descarregats += llegits;
                    // El limit es comprova TAMBE durant la descarrega:
                    // el Content-Length pot mentir o no venir.
                    if (descarregats > MAXIMA_PORTADA) {
                        throw new BiblioTechException(
                                "La portada supera el limit durant la descarrega");
                    }
                    // write(bufer, 0, llegits): mai bufer.length (modul 7).
                    sortida.write(bufer, 0, llegits);
                }
            }

            // Moviment atomic: el fitxer final apareix complet o no apareix.
            Files.move(temporal, desti,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
            temporal = null;    // ja no cal netejar-lo

            long total = descarregats;
            LOG.info(() -> "Portada descarregada: " + desti + " (" + total + " bytes)");
            return desti;

        } catch (SocketTimeoutException e) {
            throw new BiblioTechException("Temps esgotat descarregant la portada.", e);

        } catch (IOException e) {
            LOG.log(Level.WARNING, "Fallada descarregant " + urlPortada, e);
            throw new BiblioTechException("No s'ha pogut descarregar la portada.", e);

        } finally {
            if (connexio != null) {
                connexio.disconnect();
            }
            // Neteja del temporal si alguna cosa ha fallat a mitges.
            if (temporal != null) {
                try {
                    Files.deleteIfExists(temporal);
                } catch (IOException e) {
                    LOG.fine("No s'ha pogut esborrar el temporal: " + temporal);
                }
            }
        }
    }

    // =================================================================
    // Utilitats
    // =================================================================

    private String llegirCos(HttpURLConnection connexio, int codi) throws IOException {
        // La distincio que tothom oblida: amb 4xx/5xx cal
        // fer servir getErrorStream, perque getInputStream LLANCA.
        InputStream entrada = (codi >= 200 && codi < 400)
                ? connexio.getInputStream()
                : connexio.getErrorStream();

        if (entrada == null) {
            return "";
        }
        Charset charset = charsetDe(connexio.getContentType());
        try (InputStream flux = entrada) {
            // readNBytes acotat, no readAllBytes: una resposta enorme
            // no pot esgotar la nostra memoria.
            byte[] bytes = flux.readNBytes(MAXIM_COS);
            if (bytes.length == MAXIM_COS) {
                LOG.warning("Resposta truncada a " + MAXIM_COS + " bytes");
            }
            return new String(bytes, charset);
        }
    }

    /**
     * Consumeix i tanca el cos encara que no ens interessi.
     * Sense aixo, la connexio no torna al pool intern i es perd
     * la reutilitzacio, que en HTTP val un viatge de xarxa complet.
     */
    private void consumirITancar(HttpURLConnection connexio, int codi) {
        try {
            InputStream entrada = (codi >= 200 && codi < 400)
                    ? connexio.getInputStream()
                    : connexio.getErrorStream();
            if (entrada != null) {
                try (InputStream flux = entrada) {
                    flux.readNBytes(MAXIM_COS);
                }
            }
        } catch (IOException e) {
            LOG.fine("Fallada consumint el cos: " + e.getMessage());
        }
    }

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

    /**
     * Extraccio de camps de JSON PER CERCA DE SUBCADENA.
     *
     * AIXO ES UN APEDACAMENT DIDACTIC, i cal dir-ho clarament.
     * Funciona amb la resposta concreta i senzilla d'aquest servei, i es
     * trenca amb: valors que continguin la subcadena cercada, escapades (\"),
     * imbricacio, arrays, espais diferents, camps en un altre ordre o
     * valors null. Analitzar JSON de veritat requereix una llibreria, i
     * aixo es fa amb JACKSON A 11-07. No portis aixo a produccio.
     */
    private Metadades analitzar(String json, String isbnDemanat)
            throws BiblioTechException {
        try {
            String titol = campText(json, "titol");
            String autor = campText(json, "autor");
            String portada = campText(json, "portada");
            int pagines = campEnter(json, "pagines");

            if (titol == null) {
                throw new BiblioTechException(
                        "Resposta del servei sense el camp 'titol'");
            }
            return new Metadades(isbnDemanat, titol,
                    autor == null ? "(desconegut)" : autor,
                    pagines, portada);

        } catch (RuntimeException e) {
            throw new BiblioTechException(
                    "No s'ha pogut interpretar la resposta del servei", e);
        }
    }

    /** Cerca "camp":"valor" i retorna el valor. Apedacament, veure el comentari. */
    private String campText(String json, String camp) {
        String marca = "\"" + camp + "\"";
        int i = json.indexOf(marca);
        if (i < 0) {
            return null;
        }
        int dosPunts = json.indexOf(':', i + marca.length());
        if (dosPunts < 0) {
            return null;
        }
        int obre = json.indexOf('"', dosPunts);
        if (obre < 0) {
            return null;
        }
        int tanca = json.indexOf('"', obre + 1);
        if (tanca < 0) {
            return null;
        }
        return json.substring(obre + 1, tanca);
    }

    /** Cerca "camp":123 i retorna el numero, o 0 si no el troba. */
    private int campEnter(String json, String camp) {
        String marca = "\"" + camp + "\"";
        int i = json.indexOf(marca);
        if (i < 0) {
            return 0;
        }
        int dosPunts = json.indexOf(':', i + marca.length());
        if (dosPunts < 0) {
            return 0;
        }
        int j = dosPunts + 1;
        while (j < json.length() && !Character.isDigit(json.charAt(j))) {
            if (json.charAt(j) == ',' || json.charAt(j) == '}') {
                return 0;
            }
            j++;
        }
        int inici = j;
        while (j < json.length() && Character.isDigit(json.charAt(j))) {
            j++;
        }
        return inici == j ? 0 : Integer.parseInt(json.substring(inici, j));
    }

    private void validarIsbn(String isbn) throws BiblioTechException {
        if (isbn == null || isbn.isBlank() || isbn.length() > 20) {
            throw new BiblioTechException("ISBN no valid");
        }
        // Llista blanca: nomes digits i guions. Impedeix injectar camins
        // ("../admin") o parametres ("?x=1") a la URL.
        for (int i = 0; i < isbn.length(); i++) {
            char c = isbn.charAt(i);
            if (!Character.isDigit(c) && c != '-') {
                throw new BiblioTechException("ISBN amb caracters no permesos");
            }
        }
    }

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

    // Descomposicio senzilla de la URL base per al constructor d'URI.
    private String hostDe(String base) throws java.net.URISyntaxException {
        return new URI(base).getHost();
    }

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

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

Provar-ho sense servei extern

Nexus Software no existeix, així que fem de servei amb nc, com a 09-02:

Terminal 1:

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

Terminal 2:

package com.nexussoftware.bibliotech.presentacio;

import com.nexussoftware.bibliotech.excepcio.BiblioTechException;
import com.nexussoftware.bibliotech.xarxa.ClientMetadades;
import com.nexussoftware.bibliotech.xarxa.ClientMetadades.Metadades;

import java.nio.file.Path;

public class ProvaMetadades {

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

        try {
            Metadades m = client.consultar("978-0000000001");
            if (m == null) {
                System.out.println("El servei no coneix aquest ISBN");
                return;
            }
            System.out.println("Titol   : " + m.titol());
            System.out.println("Autor   : " + m.autor());
            System.out.println("Pagines : " + m.pagines());
            System.out.println("Portada : " + m.urlPortada());

            if (m.urlPortada() != null) {
                Path desti = Path.of("portades", m.isbn() + ".jpg");
                client.descarregarPortada(m.urlPortada(), desti);
                System.out.println("Desada a " + desti.toAbsolutePath());
            }

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

Proves que convé fer

  1. Respon amb un 404 des de nc i comprova que consultar retorna null sense excepció.
  2. Respon amb un 500 i un cos d'error JSON. Veuràs al log el cos de l'error gràcies a getErrorStream(); amb getInputStream() hauries tingut només una IOException sense informació.
  3. No responguis res i espera. Als deu segons salta el setReadTimeout. Treu-lo i comprova que espera indefinidament.
  4. Compara amb curl -v la mateixa petició. És la forma de saber si el problema és teu o del servidor.
  5. Posa un ISBN amb ../: client.consultar("../admin") es rebutja a la validació, abans de tocar la xarxa.

  1. La valoració honesta d'aquesta API

Has après a fer-la servir bé. Ara la valoració sincera de per què no la faries servir per a codi nou.

Problema Detall
Verbositat Una petició senzilla són 30 línies amb la seva gestió de recursos
Configuració per efectes secundaris setDoOutput(true) canvia el mètode a POST sense dir-ho
getInputStream davant de getErrorStream Una distinció que no hauria d'existir i que tothom oblida
Sense temps límit total Només per operació: un servidor lent et pot retenir indefinidament
No segueix redireccions entre esquemes http→https falla, al contrari que a curl i als navegadors
Objecte mutable amb estats Configurar després de connectar falla en silenci o llança IllegalStateException
Sense asincronia Tota petició bloqueja el fil
Només HTTP/1.1 Ni HTTP/2 ni multiplexació
Sense WebSocket Fora del seu abast
URL.equals() fa DNS Conseqüència del disseny de 1996
Difícil de provar No hi ha interfície que substituir; cal interceptar amb URLStreamHandler

Tot això ho resol HttpClient de Java 11, que veuràs a 09-06.

Aleshores, per què aprendre-la? Per tres raons sòlides:

  1. És pertot arreu. Milions de línies de codi Java en producció la fan servir. La llegiràs i la mantindràs.
  2. Ensenya HTTP amb les mans. En ser verbosa, obliga a conèixer els mètodes, els codis, les capçaleres i els fluxos. Amb HttpClient funciona tot tan bé que es pot fer servir sense entendre què passa a sota.
  3. Els conceptes es transfereixen íntegres. Mètodes, codis d'estat, capçaleres, temps límit, redireccions, charsets: tot això reapareix a l'API moderna amb millor embolcall. El difícil d'HTTP no és l'API, és HTTP.

Errors Comuns i Consells

Suposar que un 404 o un 500 llança excepció. L'error número u. Una resposta d'error és una resposta vàlida i correctament lliurada. Comprova getResponseCode() sempre.

Fer servir getInputStream() amb un codi d'error. Llança IOException i et treu el cos de l'error, que és justament el que explica el problema. Amb 4xx i 5xx, getErrorStream().

No posar temps límit. Per defecte són infinits. Un servei extern que accepta i no respon bloqueja el teu fil per sempre, i amb un pool acotat, esgota el pool sense ni un sol error al log.

Configurar després de connectar. openConnection() no connecta, però getResponseCode() sí. Tota configuració va abans.

Oblidar que setDoOutput(true) canvia el mètode a POST. Parany clàssic i silenciós.

Calcular Content-Length amb String.length(). Són caràcters, no bytes. Amb accents no coincideixen i la petició surt corrupta. Fes servir getBytes(UTF_8).length.

Fer servir URLEncoder per a segments de camí. Codifica l'espai com a +, que en un camí és un + literal. Per a camins, el constructor multiargument d'URI.

Fer servir URLEncoder.encode(String) sense charset. Està obsolet i fa servir la codificació de la plataforma: la mateixa URL surt diferent a cada sistema.

Comparar objectes URL o ficar-los en un HashSet. equals() i hashCode() fan resolució DNS: bloquegen i depenen de la xarxa. Fes servir URI.

Suposar UTF-8 sense llegir el Content-Type. Funciona gairebé sempre i et dóna text corrupte justament quan no t'ho esperes.

Fer servir readAllBytes() sobre una resposta remota. Sense límit de mida, una resposta enorme —o maliciosa— esgota la teva memòria. readNBytes amb un sostre.

No consumir el cos quan no interessa. La connexió no torna al pool intern i perds la reutilització, que en HTTP costa un viatge de xarxa complet.

Posar Accept-Encoding: gzip a mà i no descomprimir. En tocar aquella capçalera es desactiva la descompressió automàtica i reps bytes comprimits. O no la toquis, o descomprimeix tu.

Desactivar la validació de certificats TLS. Elimina tota la seguretat d'HTTPS i obre la porta a un atac d'intermediari. Si el certificat és intern, importa'l (12-07).

Descarregar directament al fitxer final. Una descàrrega interrompuda deixa un fitxer corrupte que sembla vàlid. Descarrega a temporal i mou en acabar.

Refiar-se del Content-Length. Pot faltar (-1) o mentir. Comprova el límit també durant la descàrrega.

Consell d'or per depurar. Quan alguna cosa no funcioni, fes la mateixa petició amb curl -v i compara. Si curl funciona i el teu codi no, la diferència és a les teves capçaleres o al mètode. I si necessites veure el que envia el teu codi Java, nc -l 8080 t'ho mostra byte a byte — el mateix truc de 09-02, que continua sent l'eina més eficaç que existeix.

Exercicis

Exercici 1: Inspector HTTP

Escriu una classe InspectorHttp amb un mètode inspeccionar(String url) que mostri un informe complet d'una URL, a l'estil de curl -v però en Java.

Requisits:

  • Descompondre i mostrar totes les parts de la URL (esquema, host, port efectiu, camí, consulta, fragment).
  • Fer primer una petició HEAD —que no descarrega el cos— i mostrar el codi d'estat, el missatge i totes les capçaleres ordenades.
  • Si el HEAD retorna 405 (mètode no permès, cosa que passa), reintentar amb GET.
  • Desactivar el seguiment automàtic de redireccions i mostrar la cadena completa de salts amb els seus codis i les seves Location, amb un límit de 5.
  • Mostrar la mida del cos, el tipus de contingut, el charset detectat i si ve comprimit.
  • Mesurar i mostrar el temps de connexió i el temps total.
  • Temps límit obligatoris i gestió diferenciada de les excepcions.

Prova'l contra nc -l 8080 amb respostes que fabriquis tu, incloent-hi una cadena de dues redireccions.

Exercici 2: Client amb reintents i respecte de Retry-After

Escriu ClientHttpResistent, un embolcall sobre HttpURLConnection que apliqui una política de reintents correcta.

Requisits:

  • Mètode get(String url) que retorni un record Resposta(int codi, String cos, Map<String,List<String>> capcaleres, int intents).
  • Reintentar només en: SocketTimeoutException, ConnectException, i codis 429, 502, 503, 504.
  • No reintentar mai en 4xx (llevat de 429) ni en UnknownHostException.
  • Espera creixent: 200 ms, 400, 800, 1600, amb un màxim de 4 intents.
  • Afegir a l'espera una component aleatòria de fins al 20 % (jitter) i explicar en un comentari quin problema evita.
  • Si la resposta porta Retry-After, respectar-lo en lloc de l'espera calculada, amb un sostre de 30 s. Accepta el format en segons (el de data es pot ignorar indicant-ho).
  • Mètode post(String url, String cos, String contentType) que no reintenti per defecte, amb un paràmetre explícit per permetre-ho, i un comentari explicant per què POST és diferent.
  • Registre de cada intent amb el logger.

Exercici 3: Sincronitzador de portades de BiblioTech

Escriu SincronitzadorPortades, que recorri el catàleg de BiblioTech i descarregui les portades que faltin.

Requisits:

  • Per a cada material del catàleg sense portada local, consultar ClientMetadades i descarregar la imatge.
  • Abans de descarregar, fer un HEAD per comprovar tipus i mida, i saltar-se les que no siguin imatge o superin 5 MB.
  • Descàrrega condicional: si el fitxer local ja existeix, enviar la capçalera If-Modified-Since amb la seva data de modificació en format HTTP i saltar-se la descàrrega si el servidor respon 304 Not Modified. Consulta el format de data HTTP i genera'l sense fer servir java.time (això és 10-05): amb SimpleDateFormat en zona GMT i Locale.US, indicant en un comentari que a 10-05 es fa millor.
  • Descàrrega a fitxer temporal i moviment atòmic.
  • Màxim 2 peticions per segon al servei, per no saturar-lo. Implementa el limitador de ritme tu mateix.
  • Informe final: descarregades, ja al dia (304), saltades, fallides, bytes totals i temps.
  • Tot seqüencial: fer-ho en paral·lel és 09-06, i ho esmentaràs en un comentari.

Solucions

Solució 1

package com.nexussoftware.bibliotech.xarxa;

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 complet d'una URL, a l'estil de curl -v.
 * Eina de diagnostic de l'equip de sistemes de Nexus Software.
 */
public class InspectorHttp {

    private static final int MAXIM_SALTS = 5;
    private static final int LIMIT_CONNEXIO_MS = 5_000;
    private static final int LIMIT_LECTURA_MS = 10_000;
    private static final int MAXIM_COS = 1024 * 1024;

    /** Un salt de la cadena de redireccions. */
    private record Salt(String url, int codi, String missatge, String desti) {
    }

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

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

        mostrarParts(url);

        List<Salt> cadena = new ArrayList<>();
        URL actual = url;
        HttpURLConnection connexio = null;

        try {
            // --- Seguir la cadena de redireccions a ma ---
            for (int salt = 0; salt <= MAXIM_SALTS; salt++) {
                connexio = obrir(actual, "HEAD");

                long t0 = System.nanoTime();
                int codi = connexio.getResponseCode();
                long msConnexio = (System.nanoTime() - t0) / 1_000_000;

                // Molts servidors no admeten HEAD i retornen 405.
                // Es reintenta amb GET, que sempre esta suportat.
                if (codi == HttpURLConnection.HTTP_BAD_METHOD) {
                    System.out.println("\n(HEAD ha retornat 405; es reintenta amb GET)");
                    connexio.disconnect();
                    connexio = obrir(actual, "GET");
                    t0 = System.nanoTime();
                    codi = connexio.getResponseCode();
                    msConnexio = (System.nanoTime() - t0) / 1_000_000;
                }

                String desti = connexio.getHeaderField("Location");
                cadena.add(new Salt(actual.toString(), codi,
                        connexio.getResponseMessage(), desti));

                if (codi < 300 || codi >= 400 || desti == null) {
                    mostrarResposta(connexio, codi, msConnexio);
                    break;
                }

                // Location pot ser relativa: resolve() ho gestiona.
                actual = actual.toURI().resolve(desti).toURL();
                connexio.disconnect();
                connexio = null;
            }

            mostrarCadena(cadena);

        } catch (UnknownHostException e) {
            System.out.println("\nNO RESOL el host: " + e.getMessage());
        } catch (ConnectException e) {
            System.out.println("\nCONNEXIO REBUTJADA: no hi ha servidor en aquest port");
        } catch (SocketTimeoutException e) {
            System.out.println("\nTEMPS ESGOTAT: el servidor no respon");
        } catch (Exception e) {
            System.out.println("\nFALLADA: " + e);
        } finally {
            if (connexio != null) {
                connexio.disconnect();
            }
        }
    }

    private HttpURLConnection obrir(URL url, String metode) throws IOException {
        HttpURLConnection c = (HttpURLConnection) url.openConnection();
        c.setRequestMethod(metode);
        c.setRequestProperty("User-Agent", "BiblioTech-Inspector/1.0");
        c.setRequestProperty("Accept", "*/*");
        c.setConnectTimeout(LIMIT_CONNEXIO_MS);
        c.setReadTimeout(LIMIT_LECTURA_MS);
        // Les gestionem nosaltres, per poder mostrar-les.
        c.setInstanceFollowRedirects(false);
        return c;
    }

    private void mostrarParts(URL url) {
        // getPort() retorna -1 si no ve explicit: cal combinar-lo
        // amb getDefaultPort() per saber el port efectiu.
        int port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort();

        System.out.println("\n--- PARTS 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", "Port", port,
                url.getPort() == -1 ? "  (per defecte de l'esquema)" : "  (explicit)");
        System.out.printf("  %-16s %s%n", "Cami",
                url.getPath().isEmpty() ? "/" : url.getPath());
        System.out.printf("  %-16s %s%n", "Consulta",
                url.getQuery() == null ? "(cap)" : url.getQuery());
        System.out.printf("  %-16s %s%n", "Fragment",
                url.getRef() == null ? "(cap)"
                        : url.getRef() + "  <- NO s'envia al servidor");
    }

    private void mostrarResposta(HttpURLConnection c, int codi, long ms)
            throws IOException {
        System.out.println("\n--- RESPOSTA ---");
        System.out.printf("  %-16s %d %s   (%s)%n", "Estat", codi,
                c.getResponseMessage(), familia(codi));
        System.out.printf("  %-16s %d ms%n", "Temps", ms);

        System.out.println("\n--- CAPCALERES ---");
        // TreeMap per ordre alfabetic. La clau null porta la linia d'estat.
        Map<String, List<String>> capcaleres = new TreeMap<>((a, b) -> {
            if (a == null) return -1;
            if (b == null) return 1;
            return a.compareToIgnoreCase(b);
        });
        capcaleres.putAll(c.getHeaderFields());

        for (Map.Entry<String, List<String>> e : capcaleres.entrySet()) {
            String nom = e.getKey() == null ? "(linia d'estat)" : e.getKey();
            for (String valor : e.getValue()) {
                System.out.printf("  %-24s %s%n", nom + ":", valor);
            }
        }

        System.out.println("\n--- CONTINGUT ---");
        System.out.printf("  %-16s %s%n", "Tipus",
                c.getContentType() == null ? "(no indicat)" : c.getContentType());
        System.out.printf("  %-16s %s%n", "Charset",
                ClientMetadades.charsetDe(c.getContentType()));
        long longitud = c.getContentLengthLong();
        System.out.printf("  %-16s %s%n", "Longitud",
                longitud < 0 ? "(no indicada: chunked o tancament)" : longitud + " bytes");
        System.out.printf("  %-16s %s%n", "Compressio",
                c.getContentEncoding() == null ? "(cap)" : c.getContentEncoding());

        // Amb HEAD no hi ha cos, pero amb el GET de reserva si.
        if ("GET".equals(c.getRequestMethod())) {
            InputStream entrada = (codi >= 200 && codi < 400)
                    ? c.getInputStream() : c.getErrorStream();
            if (entrada != null) {
                try (InputStream flux = entrada) {
                    byte[] bytes = flux.readNBytes(MAXIM_COS);
                    System.out.printf("  %-16s %d bytes llegits%n",
                            "Cos real", bytes.length);
                    String text = new String(bytes,
                            ClientMetadades.charsetDe(c.getContentType()));
                    System.out.println("\n--- PRIMERES LINIES DEL COS ---");
                    String[] linies = text.split("\n", 6);
                    for (int i = 0; i < Math.min(5, linies.length); i++) {
                        // Retallem: mai bolcar sense limit dades de la xarxa.
                        String l = linies[i];
                        System.out.println("  " + (l.length() > 100
                                ? l.substring(0, 100) + "..." : l));
                    }
                }
            }
        }
    }

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

    private String familia(int codi) {
        return switch (codi / 100) {
            case 1 -> "informatiu";
            case 2 -> "EXIT";
            case 3 -> "redireccio";
            case 4 -> "ERROR DEL CLIENT";
            case 5 -> "ERROR DEL SERVIDOR";
            default -> "desconegut";
        };
    }

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

Sortida contra un nc que respon una redirecció i després un 200:

==============================================================================
INSPECCIO DE: http://localhost:8080/v1/llibres/978-0000000001?format=json#resum
==============================================================================

--- PARTS DE LA URL ---
  Esquema          http
  Host             localhost
  Port             8080  (explicit)
  Cami             /v1/llibres/978-0000000001
  Consulta         format=json
  Fragment         resum  <- NO s'envia al servidor

--- RESPOSTA ---
  Estat            200 OK   (EXIT)
  Temps            4 ms

--- CAPCALERES ---
  (linia d'estat):         HTTP/1.1 200 OK
  Content-Length:          49
  Content-Type:            application/json; charset=utf-8

--- CONTINGUT ---
  Tipus            application/json; charset=utf-8
  Charset          UTF-8
  Longitud         49 bytes
  Compressio       (cap)

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

Comentaris. Quatre detalls que aquest exercici ensenya. El fragment no apareix en cap capçalera enviada: es mostra a les parts de la URL i després desapareix, que és exactament el comportament correcte. getPort() retorna -1 quan el port no ve explícit i cal combinar-lo amb getDefaultPort() — és un error habitual intentar connectar al port -1. La Location pot ser relativa (/v2/llibres/...), cosa perfectament legal segons l'estàndard i que trenca qualsevol codi que la tracti com a URL absoluta; URI.resolve() ho gestiona. I el 405 amb HEAD passa a la pràctica més del que sembla: força servidors només implementen GET, i una eina de diagnòstic ho ha de preveure.

Solució 2

package com.nexussoftware.bibliotech.xarxa;

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;

/**
 * Client HTTP amb politica de reintents correcta.
 *
 * La regla que ho governa tot: es reintenta el TRANSITORI
 * (temps esgotats, 429, 5xx d'infraestructura) i mai el
 * PERMANENT (4xx, host inexistent).
 */
public class ClientHttpResistent {

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

    private static final int MAXIM_INTENTS = 4;
    private static final int ESPERA_INICIAL_MS = 200;
    private static final int MAXIM_RETRY_AFTER_S = 30;
    private static final int MAXIM_COS = 1024 * 1024;
    private static final int LIMIT_CONNEXIO_MS = 5_000;
    private static final int LIMIT_LECTURA_MS = 10_000;

    public record Resposta(int codi, String cos,
                           Map<String, List<String>> capcaleres, int intents) {

        public boolean exit() {
            return codi >= 200 && codi < 300;
        }
    }

    // =================================================================
    // GET: idempotent, es reintenta
    // =================================================================

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

    // =================================================================
    // POST: NO idempotent, NO es reintenta per defecte
    // =================================================================

    /**
     * POST sense reintents.
     *
     * PER QUE ES DIFERENT: si un POST esgota el temps d'espera, NO SABEM
     * si el servidor l'ha processat. Pot ser que la peticio arribes, s'executes
     * i nomes es perdes la resposta. Reintentar duplicaria l'operacio:
     * dos prestecs, dos carrecs, dues comandes.
     *
     * La solucio professional es la CLAU D'IDEMPOTENCIA: el client genera
     * un identificador unic per operacio, l'envia en una capcalera
     * (Idempotency-Key) i el servidor rebutja la segona peticio amb la
     * mateixa clau. Amb aixo, reintentar SI que es segur. Sense aixo, no.
     */
    public Resposta post(String url, String cos, String contentType)
            throws IOException {
        return executar(url, "POST", cos, contentType, false);
    }

    /** POST amb reintents: nomes si el servei garanteix idempotencia. */
    public Resposta postIdempotent(String url, String cos, String contentType)
            throws IOException {
        return executar(url, "POST", cos, contentType, true);
    }

    // =================================================================
    // Nucli
    // =================================================================

    private Resposta executar(String urlText, String metode, String cos,
                              String contentType, boolean reintentar)
            throws IOException {

        URL url = URI.create(urlText).toURL();
        int espera = ESPERA_INICIAL_MS;
        IOException ultimaFallada = null;
        int maxim = reintentar ? MAXIM_INTENTS : 1;

        for (int intent = 1; intent <= maxim; intent++) {
            HttpURLConnection connexio = null;
            try {
                connexio = (HttpURLConnection) url.openConnection();
                connexio.setRequestMethod(metode);
                connexio.setRequestProperty("User-Agent", "BiblioTech/1.0");
                connexio.setRequestProperty("Accept", "application/json, */*");
                connexio.setConnectTimeout(LIMIT_CONNEXIO_MS);
                connexio.setReadTimeout(LIMIT_LECTURA_MS);

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

                int codi = connexio.getResponseCode();
                Map<String, List<String>> capcaleres = connexio.getHeaderFields();

                final int n = intent;
                LOG.fine(() -> metode + " " + url + " -> " + codi
                        + " (intent " + n + ")");

                // Codi transitori: mereix reintent si queda marge.
                if (esTransitori(codi) && intent < maxim) {
                    long esperaMs = esperaPerCodi(connexio, codi, espera);
                    consumir(connexio, codi);
                    LOG.warning(metode + " " + url + " -> " + codi
                            + "; reintent " + (intent + 1) + " d'aqui a " + esperaMs + " ms");
                    dormir(esperaMs);
                    espera *= 2;
                    continue;
                }

                String text = llegirCos(connexio, codi);
                return new Resposta(codi, text, capcaleres, intent);

            } catch (UnknownHostException e) {
                // PERMANENT: reintentar no fara que el nom existeixi.
                LOG.severe("Host no resoluble: " + url.getHost());
                throw e;

            } catch (SocketTimeoutException | ConnectException e) {
                // TRANSITORIS: es poden resoldre sols.
                ultimaFallada = e;
                if (intent >= maxim) {
                    break;
                }
                long esperaMs = ambJitter(espera);
                LOG.warning(e.getClass().getSimpleName() + " a " + url
                        + "; reintent " + (intent + 1) + " d'aqui a " + esperaMs + " ms");
                dormir(esperaMs);
                espera *= 2;

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

        throw ultimaFallada != null ? ultimaFallada
                : new IOException("Sense resposta despres de " + maxim + " intents");
    }

    // =================================================================
    // Politica de reintents
    // =================================================================

    private boolean esTransitori(int codi) {
        // 429: ens demanen baixar el ritme. 502/503/504: fallades d'infraestructura.
        // El 500 NO s'hi inclou: sol ser una fallada determinista que es repetira.
        // Els 4xx tampoc: la peticio esta malament i reintentar-la donara el mateix.
        return codi == 429 || codi == 502 || codi == 503 || codi == 504;
    }

    /** Si el servidor diu quant esperar, se li fa cas. */
    private long esperaPerCodi(HttpURLConnection connexio, int codi, int calculada) {
        String retryAfter = connexio.getHeaderField("Retry-After");
        if (retryAfter != null) {
            try {
                // Retry-After admet dos formats: segons, o una data HTTP.
                // Aqui nomes tractem el de segons; el de data requereix
                // analitzar dates, i aixo es fa be a 10-05.
                long segons = Long.parseLong(retryAfter.strip());
                long limitat = Math.min(segons, MAXIM_RETRY_AFTER_S);
                LOG.info("El servidor demana esperar " + segons
                        + " s (apliquem " + limitat + " s)");
                return limitat * 1000;
            } catch (NumberFormatException e) {
                LOG.fine("Retry-After en format de data; s'ignora: " + retryAfter);
            }
        }
        return ambJitter(calculada);
    }

    /**
     * Afegeix una component aleatoria de fins al 20 %.
     *
     * QUIN PROBLEMA EVITA: el "ramat atronador". Si cent clients fallen a la
     * vegada perque el servidor ha caigut, i tots reintenten exactament als
     * 200 ms, les cent peticions tornen a arribar juntes i el tomben una altra
     * vegada, en un cicle que es repeteix indefinidament. Escampar els
     * reintents trenca la sincronitzacio i reparteix la carrega en el temps.
     */
    private long ambJitter(long base) {
        long variacio = (long) (base * 0.2);
        return base + ThreadLocalRandom.current().nextLong(-variacio, variacio + 1);
    }

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

    // =================================================================
    // Utilitats
    // =================================================================

    private String llegirCos(HttpURLConnection connexio, int codi) throws IOException {
        InputStream entrada = (codi >= 200 && codi < 400)
                ? connexio.getInputStream() : connexio.getErrorStream();
        if (entrada == null) {
            return "";
        }
        try (InputStream flux = entrada) {
            byte[] bytes = flux.readNBytes(MAXIM_COS);
            return new String(bytes, ClientMetadades.charsetDe(connexio.getContentType()));
        }
    }

    /** Consumeix el cos perque la connexio torni al pool intern. */
    private void consumir(HttpURLConnection connexio, int codi) {
        try {
            InputStream entrada = (codi >= 200 && codi < 400)
                    ? connexio.getInputStream() : connexio.getErrorStream();
            if (entrada != null) {
                try (InputStream flux = entrada) {
                    flux.readNBytes(MAXIM_COS);
                }
            }
        } catch (IOException e) {
            LOG.fine("Fallada consumint el cos: " + e.getMessage());
        }
    }
}

Prova amb un nc que respongui 503 amb Retry-After:

WARNING: GET http://localhost:8080/v1/llibres -> 503; reintent 2 d'aqui a 2000 ms
INFO: El servidor demana esperar 2 s (apliquem 2 s)
WARNING: GET http://localhost:8080/v1/llibres -> 503; reintent 3 d'aqui a 431 ms
Resposta[codi=200, intents=3]

Comentaris. Tres idees centrals.

La taula de què es reintenta és el cor de l'exercici, i cada exclusió té la seva raó. Els 4xx no es reintenten perquè la petició està mal formada i tornar a enviar el mateix donarà el mateix. El 500 s'exclou encara que sigui 5xx perquè sol indicar una fallada determinista —un error de programació al servidor— que es repetirà idènticament. UnknownHostException no es reintenta perquè un nom que no existeix no començarà a existir en 400 ms.

El jitter sembla un detall menor i no ho és. Sense ell, cent clients que fallen simultàniament reintenten simultàniament, i la ràfega sincronitzada torna a tombar el servei que s'estava recuperant. Es repeteix indefinidament. Amb un 20 % d'aleatorietat, els reintents s'escampen i el servidor rep una càrrega gradual que sí que pot absorbir. És una tècnica estàndard en qualsevol sistema distribuït seriós.

Respectar Retry-After amb un sostre combina cortesia i prudència: es fa cas al servidor, que sap millor que tu quan estarà a punt, però amb un límit, perquè un Retry-After: 3600 no pot deixar el teu fil bloquejat una hora. I fixa't que el format de data s'ignora explícitament indicant per què: analitzar dates HTTP correctament és feina de java.time, que és 10-05.

Solució 3

package com.nexussoftware.bibliotech.xarxa;

import com.nexussoftware.bibliotech.domini.Material;
import com.nexussoftware.bibliotech.excepcio.BiblioTechException;
import com.nexussoftware.bibliotech.servei.CatalegConcurrent;
import com.nexussoftware.bibliotech.xarxa.ClientMetadades.Metadades;

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;

/**
 * Sincronitza les portades del cataleg de BiblioTech amb el servei
 * extern de metadades. Sequencial i respectuos amb el servei.
 */
public class SincronitzadorPortades {

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

    private static final long MAXIMA_PORTADA = 5L * 1024 * 1024;
    private static final int PETICIONS_PER_SEGON = 2;
    private static final int LIMIT_CONNEXIO_MS = 5_000;
    private static final int LIMIT_LECTURA_MS = 30_000;
    private static final String AGENT = "BiblioTech/1.0";

    private final CatalegConcurrent cataleg;
    private final ClientMetadades metadades;
    private final Path directori;

    /** Instant de l'ultim acces a la xarxa, per al limitador de ritme. */
    private long ultimaPeticio = 0;

    private int descarregades = 0;
    private int alDia = 0;
    private int saltades = 0;
    private int fallides = 0;
    private long bytesTotals = 0;

    public SincronitzadorPortades(CatalegConcurrent cataleg,
                                  ClientMetadades metadades, Path directori) {
        this.cataleg = cataleg;
        this.metadades = metadades;
        this.directori = directori;
    }

    public void sincronitzar() throws IOException {
        Files.createDirectories(directori);
        long inici = System.currentTimeMillis();

        List<Material> materials = cataleg.tots();
        System.out.println("Sincronitzant portades de " + materials.size()
                + " materials...\n");

        // SEQUENCIAL a proposit. Fer-ho en PARALLEL -que reduiria el
        // temps total dracticament, perque gairebe tot es espera de xarxa-
        // requereix l'HttpClient asincron amb sendAsync i allOf, i aixo
        // es exactament el que es fa a 09-06.
        for (Material material : materials) {
            processar(material);
        }

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

    private void processar(Material material) {
        String isbn = material.getIsbn();
        Path desti = directori.resolve(isbn + ".jpg");

        try {
            limitarRitme();
            Metadades m = metadades.consultar(isbn);

            if (m == null || m.urlPortada() == null || m.urlPortada().isBlank()) {
                System.out.printf("  %-18s SALTADA (sense portada al servei)%n", isbn);
                saltades++;
                return;
            }

            limitarRitme();
            long bytes = descarregarSiHaCanviat(m.urlPortada(), desti, isbn);

            if (bytes < 0) {
                alDia++;            // 304: ja la teniem actualitzada
            } else if (bytes == 0) {
                saltades++;         // rebutjada per tipus o mida
            } else {
                descarregades++;
                bytesTotals += bytes;
            }

        } catch (BiblioTechException | IOException e) {
            fallides++;
            System.out.printf("  %-18s FALLADA: %s%n", isbn, e.getMessage());
            LOG.log(Level.FINE, "Fallada sincronitzant " + isbn, e);
        }
    }

    /**
     * Descarrega la portada si ha canviat.
     * @return bytes descarregats; -1 si el servidor ha retornat 304; 0 si s'ha rebutjat.
     */
    private long descarregarSiHaCanviat(String urlText, Path desti, String isbn)
            throws IOException, BiblioTechException {

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

        // --- Fase 1: HEAD per comprovar tipus i mida ---
        HttpURLConnection connexioHead = (HttpURLConnection) url.openConnection();
        try {
            connexioHead.setRequestMethod("HEAD");
            connexioHead.setRequestProperty("User-Agent", AGENT);
            connexioHead.setConnectTimeout(LIMIT_CONNEXIO_MS);
            connexioHead.setReadTimeout(LIMIT_LECTURA_MS);

            int codi = connexioHead.getResponseCode();
            if (codi == HttpURLConnection.HTTP_OK) {
                String tipus = connexioHead.getContentType();
                long longitud = connexioHead.getContentLengthLong();

                if (tipus != null && !tipus.startsWith("image/")) {
                    System.out.printf("  %-18s SALTADA (no es imatge: %s)%n", isbn, tipus);
                    return 0;
                }
                if (longitud > MAXIMA_PORTADA) {
                    System.out.printf("  %-18s SALTADA (%d bytes, supera el limit)%n",
                            isbn, longitud);
                    return 0;
                }
            }
            // Un HEAD que falla no impedeix intentar el GET: molts
            // servidors no l'implementen be.
        } finally {
            connexioHead.disconnect();
        }

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

            // DESCARREGA CONDICIONAL: si ja tenim el fitxer, demanem al
            // servidor que nomes ens l'enviï si ha canviat des d'aleshores.
            // Un 304 estalvia la transferencia sencera.
            if (Files.exists(desti)) {
                long modificat = Files.getLastModifiedTime(desti).toMillis();
                connexio.setRequestProperty("If-Modified-Since",
                        dataHttp(new Date(modificat)));
            }

            int codi = connexio.getResponseCode();

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

            // Descarrega a TEMPORAL: una interrupcio no deixa un JPEG a mitges
            // que sembla valid fins que algu intenta obrir-lo.
            temporal = Files.createTempFile(directori, "portada-", ".tmp");
            long descarregats = 0;

            try (InputStream entrada = connexio.getInputStream();
                 OutputStream sortida = Files.newOutputStream(temporal)) {
                byte[] bufer = new byte[8192];
                int llegits;
                while ((llegits = entrada.read(bufer)) != -1) {
                    descarregats += llegits;
                    // El limit es comprova TAMBE aqui: el Content-Length
                    // del HEAD pot faltar o mentir.
                    if (descarregats > MAXIMA_PORTADA) {
                        throw new IOException("La portada supera el limit descarregant");
                    }
                    sortida.write(bufer, 0, llegits);   // mai bufer.length
                }
            }

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

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

        } finally {
            connexio.disconnect();
            if (temporal != null) {
                try {
                    Files.deleteIfExists(temporal);
                } catch (IOException e) {
                    LOG.fine("No s'ha pogut esborrar el temporal " + temporal);
                }
            }
        }
    }

    /**
     * Format de data HTTP (RFC 7231): "Wed, 05 Aug 2026 09:14:22 GMT".
     *
     * Es fa servir SimpleDateFormat perque java.time es 10-05. Dos detalls
     * imprescindibles i que gairebe tothom oblida:
     *   - Locale.US: sense ell, els noms de dia i mes surten en l'idioma
     *     del sistema ("dc", "ago") i el servidor no els enten.
     *   - Zona GMT: el format ho exigeix explicitament.
     * A 10-05 aixo es fa amb DateTimeFormatter.RFC_1123_DATE_TIME,
     * que es immutable i segur per a diversos fils; SimpleDateFormat NO ho es.
     */
    private String dataHttp(Date data) {
        SimpleDateFormat format = new SimpleDateFormat(
                "EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
        format.setTimeZone(TimeZone.getTimeZone("GMT"));
        return format.format(data);
    }

    /**
     * Limitador de ritme: no mes de PETICIONS_PER_SEGON al servei.
     * Ser un bon ciutada evita que et bloquegin la IP, i evita
     * provocar els 429 que despres caldria gestionar.
     */
    private void limitarRitme() {
        long interval = 1000 / PETICIONS_PER_SEGON;
        long desDe = System.currentTimeMillis() - ultimaPeticio;
        if (desDe < interval) {
            try {
                Thread.sleep(interval - desDe);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();     // 08-02
            }
        }
        ultimaPeticio = System.currentTimeMillis();
    }

    private void informe(long ms) {
        System.out.println();
        System.out.println("=== SINCRONITZACIO DE PORTADES ===");
        System.out.printf("%-22s %d%n", "Descarregades", descarregades);
        System.out.printf("%-22s %d%n", "Ja al dia (304)", alDia);
        System.out.printf("%-22s %d%n", "Saltades", saltades);
        System.out.printf("%-22s %d%n", "Fallides", fallides);
        System.out.printf("%-22s %.1f KB%n", "Bytes descarregats", bytesTotals / 1024.0);
        System.out.printf("%-22s %.1f s%n", "Temps total", ms / 1000.0);
        System.out.printf("%-22s %s%n", "Directori", directori.toAbsolutePath());
    }
}

Sortida típica:

Sincronitzant portades de 5 materials...

  978-0000000001     DESCARREGADA (48213 bytes)
  978-0000000002     AL DIA (304, no ha canviat)
  978-0000000003     DESCARREGADA (39104 bytes)
  978-0000000004     SALTADA (sense portada al servei)
  978-0000000005     FALLADA: HTTP 404 descarregant la portada

=== SINCRONITZACIO DE PORTADES ===
Descarregades          2
Ja al dia (304)        1
Saltades               1
Fallides               1
Bytes descarregats     85.3 KB
Temps total            5.4 s

Comentaris. Quatre punts.

La descàrrega condicional amb If-Modified-Since és l'optimització més rendible d'HTTP i gairebé ningú no la fa servir. Un 304 té cos buit: estalvia la transferència completa a canvi d'un viatge de xarxa. En un catàleg de mil llibres amb portades de 50 KB, sincronitzar sense condicional mou 50 MB cada vegada; amb condicional, uns pocs kilobytes llevat que alguna cosa hagi canviat de veritat.

El format de data HTTP té dos paranys i tots dos són al codi. Sense Locale.US, un sistema en català genera dc., 05 ago. 2026 i el servidor l'ignora en silenci, amb la qual cosa la condicional deixa de funcionar sense que ningú se n'assabenti. I sense setTimeZone("GMT"), la data surt en hora local i el servidor la interpreta com a GMT, amb un desfasament d'una o dues hores. És exactament el tipus de problema que java.time resol d'arrel a 10-05.

El limitador de ritme és cortesia i prudència alhora. Un client que dispara mil peticions en dos segons acaba amb la IP bloquejada, i mentrestant provoca els 429 que després caldria gestionar amb reintents. Dos per segon és lent però sostenible.

I el TODO implícit que apunta a la lliçó següent. Sincronitzar cinc portades triga 5,4 segons, i pràcticament tot aquest temps és espera de xarxa: la CPU està aturada. Mil portades trigarien gairebé vint minuts. Com que les descàrregues són independents entre elles, fer-les en paral·lel reduiria el temps gairebé pel factor de paral·lelisme — i això és exactament el que faràs a 09-06 amb sendAsync i allOf. Amb HttpURLConnection caldria muntar el pool i les tasques a mà; amb l'API moderna, és una cadena de tres crides.

Conclusió

Has pujat un nivell: has deixat d'inventar protocols i has après a parlar el que entén tothom.

Saps descompondre una URL en les seves sis parts —esquema, host, port, camí, consulta i fragment— amb els detalls que mosseguen: el fragment no s'envia mai al servidor i getPort() retorna -1 quan no ve explícit. I coneixes la diferència entre URL i URI i la regla que se'n deriva: fes servir URI per representar, manipular i comparar, i converteix a URL només per connectar, perquè URL.equals() fa resolució DNS i converteix una simple comparació en una operació de xarxa que bloqueja.

Manages la codificació de paràmetres i saps per què és imprescindible: sense ella un espai trenca la petició, un accent arriba corrupte i un & en un valor injecta paràmetres que el servidor interpreta com a propis. Amb la distinció que gairebé ningú no coneix: URLEncoder codifica l'espai com a +, cosa correcta en un valor de consulta i errònia en un segment de camí, on cal fer servir el constructor multiargument d'URI.

I sobretot entens HTTP, no el fas servir a cegues. Saps que una petició és una línia de mètode, camí i versió, unes capçaleres, una línia buida obligatòria i un cos opcional; que el delimitador és \r\n i no \n; que la capçalera Host és obligatòria perquè és la que permet l'allotjament virtual. Saps que la resposta té la mateixa forma amb una línia d'estat, i com es marca el final del cos: Content-Length, Transfer-Encoding: chunked o el tancament de la connexió — les tres tècniques del problema del delimitador de 09-01, totes juntes al mateix protocol. I ho has vist byte a byte fent tu de servidor HTTP amb nc, exactament com vas fer de servidor BTCP a 09-02: HTTP és text sobre un socket TCP, i això és tot.

Coneixes els mètodes i les dues propietats que governen el seu ús: segur —no modifica res, es pot cachejar i precarregar— i idempotent —repetir-lo dóna el mateix resultat—, que és la propietat que decideix si pots reintentar després d'un temps esgotat. GET, PUT i DELETE sí; POST i PATCH no, perquè un reintent pot duplicar l'operació. I coneixes els codis d'estat per famílies, amb la regla que vas aplicar al teu propi BTCP/1: el primer dígit decideix sense llegir el text. Amb la llista de quins mereixen reintent —429 respectant Retry-After, i 502, 503, 504— i quins no.

Saps fer servir HttpURLConnection correctament, que no és poc: openConnection() no connecta —ho fa getResponseCode(), i per això tota la configuració va abans—; temps límit obligatoris de connexió i de lectura, tots dos amb valor per defecte infinit; getErrorStream() amb 4xx i 5xx, perquè getInputStream() llança i et treu el cos de l'error que explica el problema; setDoOutput(true) per enviar cos, amb el parany que canvia el mètode a POST sense avisar i amb el Content-Length mesurat en bytes, no caràcters; redireccions automàtiques que no salten entre http i https; i gzip transparent llevat que toquis Accept-Encoding, cas en què et toca descomprimir.

I saps el que cal saber d'HTTPS: que funciona sol, que els errors de certificat tenen causes identificables, i la regla que no es negocia: no desactivis mai la validació de certificats, perquè converteix HTTPS en HTTP amb passos extra i obre la porta a un intermediari (12-07).

BiblioTech ha començat a parlar amb el món exterior. ClientMetadades consulta el servei de Nexus Software per ISBN i descarrega portades a disc combinant HTTP amb el NIO.2 del mòdul 7: validació d'esquema perquè una URL file:// no li faci llegir fitxers locals, comprovació del tipus i de la mida abans i durant la descàrrega perquè el Content-Length pot mentir, escriptura a temporal i moviment atòmic perquè una interrupció no deixi un JPEG a mitges, lectura acotada amb readNBytes en lloc de readAllBytes, i traducció de cada fallada a BiblioTechException distingint transitori de permanent. Més ConsultaUrl, InspectorHttp, ClientHttpResistent amb la seva política de reintents i el seu jitter contra el ramat atronador, i SincronitzadorPortades amb la seva descàrrega condicional i el seu limitador de ritme.

I has vist, amb honestedat, l'apedaçament del JSON: extreure camps cercant subcadenes funciona amb la resposta concreta d'aquest servei i es trenca amb escapades, imbricació, arrays o un canvi en l'ordre dels camps. Està assenyalat com el que és —un apedaçament didàctic— perquè fer-ho bé requereix Jackson, i això és 11-07.

Amb la valoració final que toca: HttpURLConnection és verbosa, amb configuració per efectes secundaris, sense temps límit total, sense asincronia, només HTTP/1.1 i difícil de provar. No la faries servir per a codi nou. Però és a tota la biblioteca estàndard, la trobaràs en codi heretat, i —l'important— t'ha ensenyat HTTP amb les mans. El difícil d'HTTP mai no va ser l'API.

A la lliçó següent, El client HTTP modern, arriba la recompensa. java.net.http de Java 11 amb les seves tres peces —HttpClient, HttpRequest, HttpResponse—, immutables i amb constructors fluids, un client que es crea una vegada i es reutilitza amb el seu pool de connexions intern, temps límit totals de veritat, HTTP/2 amb multiplexació, i HttpResponse<T> amb gestors de cos que et donen text, línies, un flux o directament un fitxer. I sobretot, el moment en què dos mòduls es troben: sendAsync retorna un CompletableFuture<HttpResponse<String>>, i tot el que vas aprendre a 08-07 —thenApply, thenCompose, exceptionally, orTimeout, allOf— s'aplica tal qual per consultar les metadades de diversos ISBN en paral·lel i compondre un informe sense bloquejar ni un sol fil. Amb el problema de rendiment que va deixar obert el sincronitzador de portades resolt en tres crides. És la lliçó de tancament del mòdul 9.

Curs de Programació en Java

Mòdul 1: Introducció a Java

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats