Java es un caballo de batalla para la recolección de datos a escala: clientes HTTP maduros, hilos reales, y tooling de la JVM que hace observables los crawlers de larga duración. Conectar un proxy residencial a cualquiera de los dos clientes que la mayoría de los equipos usa, OkHttp y Apache HttpClient, es sencillo. La fricción está en los detalles que cada librería maneja de forma distinta: cómo se suministra la autenticación del proxy, un default del pool de conexiones que te limita en silencio, y la regla sobre consumir las respuestas que decide si el pooling funciona siquiera.
Esta es la entrada de Java de la misma serie que proxies residenciales con Python, con Playwright, y con Go: el código que funciona para ambos clientes, más las trampas específicas de Java.
Todo lo de abajo usa el gateway residencial de Shifter: un endpoint, p.shifter.io:443, con todo el targeting codificado en el nombre de usuario. Cambia host y credenciales para otro proveedor; la forma es la misma.
El modelo del gateway en un párrafo
El nombre de usuario del proxy lleva tu autenticación y tu targeting. No cambias de endpoint para cambiar de país o sesión, cambias la cadena del nombre de usuario:
customer-USERNAME-country-us-sid-abc123-ttl-600country-us apunta a EE. UU., sid fija una sesión sticky, ttl mantiene esa IP durante N segundos. Omite sid/ttl y cada nueva conexión rota. La contraseña es constante. Una arruga que vale la pena señalar de entrada: en ambos clientes de Java, las credenciales del proxy no se ponen en la URL del proxy, van a través de un mecanismo de autenticación dedicado. Eso es lo que más comúnmente se equivoca la gente.
OkHttp
OkHttp toma un objeto Proxy para la dirección y un proxyAuthenticator separado para las credenciales. No intentes codificar user:pass@ en una URL; OkHttp no lo leerá.
import okhttp3.*;import java.net.InetSocketAddress;import java.net.Proxy;import java.io.IOException;
public class ProxyExample { public static void main(String[] args) throws IOException { String user = System.getenv("SHIFTER_USER") + "-country-us"; String pass = System.getenv("SHIFTER_PASS");
OkHttpClient client = new OkHttpClient.Builder() .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("p.shifter.io", 443))) .proxyAuthenticator((route, response) -> { // Llamado cuando el proxy devuelve 407. Adjunta Proxy-Authorization. String credential = Credentials.basic(user, pass); return response.request().newBuilder() .header("Proxy-Authorization", credential) .build(); }) .build();
Request request = new Request.Builder().url("https://api.ipify.org").build(); try (Response response = client.newCall(request).execute()) { // try-with-resources cierra el body System.out.println(response.body().string()); // una IP residencial de EE. UU. } }}Dos cosas que interiorizar. La cadena user incluye los flags de targeting (-country-us), porque ahí es donde vive la geo. Y el try-with-resources alrededor del Response no es estilo opcional, cierra el body de la respuesta, que es lo que devuelve la conexión al pool.
Reutiliza el cliente. OkHttpClient está diseñado para crearse una vez y compartirse; mantiene el pool de conexiones y el dispatcher de hilos, y es thread-safe. Crear uno por petición tira el pooling y filtra recursos. Para variar la identidad por petición, deriva una variante con newBuilder(), que comparte el pool y el dispatcher subyacentes:
// Un cliente base, compartido. Las variantes por identidad reutilizan su pool + dispatcher.OkHttpClient forGeo(OkHttpClient base, String country, String sid) { String user = System.getenv("SHIFTER_USER") + "-country-" + country + (sid != null ? "-sid-" + sid + "-ttl-600" : ""); String pass = System.getenv("SHIFTER_PASS"); return base.newBuilder() .proxyAuthenticator((route, resp) -> resp.request().newBuilder() .header("Proxy-Authorization", Credentials.basic(user, pass)) .build()) .build();}Dale a cada unidad lógica de trabajo su propio sid y rota entre unidades, no a mitad de flujo (sticky vs rotating cubre la distinción).
Apache HttpClient (5.x)
Apache HttpClient suministra el proxy a través del request config o un route planner, y las credenciales a través de un CredentialsProvider con alcance al host del proxy.
import org.apache.hc.client5.http.classic.methods.HttpGet;import org.apache.hc.client5.http.impl.classic.*;import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;import org.apache.hc.client5.http.auth.*;import org.apache.hc.client5.http.config.RequestConfig;import org.apache.hc.core5.http.HttpHost;import org.apache.hc.core5.util.Timeout;
public class ApacheProxyExample { public static void main(String[] args) throws Exception { HttpHost proxy = new HttpHost("http", "p.shifter.io", 443); String user = System.getenv("SHIFTER_USER") + "-country-us"; char[] pass = System.getenv("SHIFTER_PASS").toCharArray();
BasicCredentialsProvider creds = new BasicCredentialsProvider(); creds.setCredentials(new AuthScope(proxy), new UsernamePasswordCredentials(user, pass));
// Pool: sube el por-ruta desde el default de 5 (ve el gotcha abajo). PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(50);
RequestConfig config = RequestConfig.custom() .setProxy(proxy) .setConnectTimeout(Timeout.ofSeconds(10)) // fase de connect .setResponseTimeout(Timeout.ofSeconds(30)) // fase de respuesta .build();
try (CloseableHttpClient client = HttpClients.custom() .setConnectionManager(cm) .setDefaultCredentialsProvider(creds) .setDefaultRequestConfig(config) .build()) {
HttpGet get = new HttpGet("https://api.ipify.org"); // try-with-resources en la respuesta consume + libera la conexión. try (var response = client.execute(get)) { System.out.println(new String(response.getEntity().getContent().readAllBytes())); } } }}Nota los timeouts de connect y de respuesta separados, esa división connect-versus-response es exactamente lo que hace los timeouts diagnosticables cuando algo se cuelga.
Gotcha 1: el max-per-route por defecto de Apache es 5
Este es el equivalente en Java de la trampa de MaxIdleConnsPerHost de Go, y muerde fuerte. PoolingHttpClientConnectionManager tiene por defecto 2 conexiones por ruta y 20 en total en builds antiguos, y un cap por-ruta bajo en 5.x. Corre 50 hilos contra un host y la mayoría bloquean esperando a que una conexión se libere, lo cual parece exactamente un proxy lento.
Pon el pool al menos a tu concurrencia por host:
cm.setMaxTotal(200);cm.setDefaultMaxPerRoute(50); // >= tu concurrencia por hostSi el throughput de tu crawler se estanca sin importar cuántos hilos añadas, este default es lo primero que comprobar.
Gotcha 2: consume la entity, o la conexión nunca vuelve
Ambos clientes agrupan conexiones, y ambos solo devuelven una conexión al pool cuando la respuesta se consume por completo y se cierra. En Apache HttpClient, una entity sin consumir mantiene la conexión retirada; haz suficiente de eso y tu pool se muere de hambre aunque nada esté técnicamente filtrando.
Usa try-with-resources en la respuesta (como arriba), o consume explícitamente:
import org.apache.hc.core5.http.io.entity.EntityUtils;// ...EntityUtils.consume(response.getEntity()); // drena + libera la conexiónLa trampa son las salidas tempranas: salir ante un status distinto de 200 sin consumir la entity deja varada la conexión. En un scraper que se topa con muchos bloqueos, eso es la mayor parte de tu tráfico, y el pool muere en silencio.
Gotcha 3: reutiliza el cliente, siempre
Tanto OkHttpClient como CloseableHttpClient son pesados, thread-safe, y están pensados para compartirse por toda la aplicación. Son dueños del pool de conexiones, y uno nuevo por petición paga un handshake TCP + TLS completo a través del proxy cada vez, el overhead que la guía de latencia existe para eliminar. Construye uno al arrancar, inyéctalo, y deriva variantes con alcance de petición solo cuando debas variar la identidad.
Rotar geo y sesiones
Como el targeting vive en el nombre de usuario, una identidad distinta es una cadena de credenciales distinta.
OkHttp: deriva un cliente con newBuilder() por identidad (mostrado arriba); reutiliza el pool compartido, así que sigue siendo eficiente.
Apache HttpClient: adjunta un HttpClientContext por petición que lleve un CredentialsProvider para esa identidad, para que un cliente sirva muchas identidades sin reconstruir:
HttpClientContext ctx = HttpClientContext.create();BasicCredentialsProvider perCall = new BasicCredentialsProvider();perCall.setCredentials(new AuthScope(proxy), new UsernamePasswordCredentials(userFor("de", "job-42"), pass));ctx.setCredentialsProvider(perCall);client.execute(get, ctx, resp -> { /* maneja */ return null; });Rota entre unidades lógicas de trabajo en lugar de dentro de una, y mapea trabajo a identidades como describe el post de balanceo de carga.
Concurrencia, por host
Acota la concurrencia por host objetivo, no con un cap global, para que un objetivo frágil no pueda ser martillado y uno permisivo no quede muerto de hambre. Un Semaphore por host es la expresión más simple:
Map<String, Semaphore> limits = Map.of( "tough-site.example", new Semaphore(4), "open-site.example", new Semaphore(32));
void fetch(String host, Runnable work) throws InterruptedException { Semaphore sem = limits.get(host); sem.acquire(); try { work.run(); } finally { sem.release(); }}Pasada la tolerancia de un objetivo, más hilos compran bloqueos, no throughput (cómo evitar que te bloqueen). Dimensiona el límite por-ruta del pool y tu semáforo por host juntos.
Verifica que de verdad estás en el proxy
Antes de hacer benchmark o depurar cualquier otra cosa, confirma la IP de salida:
Request req = new Request.Builder().url("http://ip-api.com/json").build();try (Response r = client.newCall(req).execute()) { System.out.println(r.body().string()); // espera una IP residencial en el país objetivo}Tu propia IP significa que el cliente no está usando el proxy. Un cuelgue significa que la salida local está bloqueada. Ambos están cubiertos en la guía de diagnóstico de timeouts.
Preguntas frecuentes
¿Por qué no funciona http://user:pass@host para el proxy en Java?
Ni OkHttp ni Apache HttpClient leen credenciales de proxy en línea de una URL. OkHttp usa un proxyAuthenticator que pone Proxy-Authorization; Apache usa un CredentialsProvider con alcance al host del proxy. Como el gateway codifica el targeting en el nombre de usuario, ese nombre de usuario va en el authenticator/credentials, no en una URL.
Mi scraper de Java se estanca bajo carga incluso con un solo cliente. ¿Por qué?
Lo más probable es que el límite por-ruta del pool de Apache (bajo por defecto) te esté limitando, o que no estés consumiendo las entities de respuesta así que las conexiones nunca vuelven al pool. Sube setDefaultMaxPerRoute a tu concurrencia y consume cada respuesta con try-with-resources.
¿Cómo roto IPs por petición en Java?
Varía el nombre de usuario del proxy. En OkHttp, deriva un cliente por identidad con newBuilder() (comparte el pool). En Apache HttpClient, pasa un HttpClientContext por petición con un CredentialsProvider para esa identidad. Ambos dejan que un cliente compartido sirva muchas identidades.
¿Debería poner los timeouts de connect y de respuesta por separado? Sí. Un timeout de connect separado y un timeout de respuesta/socket te dejan distinguir un connect lento (tu lado o ninguna IP coincidente) de una respuesta lenta (el objetivo), que es la distinción clave al diagnosticar timeouts. Un único timeout romo esconde qué fase falló.
¿OkHttp o Apache HttpClient para scraping? Cualquiera funciona bien. OkHttp es más ligero y tiene una API más limpia; Apache HttpClient es más configurable y de larga trayectoria. Elige por ergonomía y dependencias existentes; la configuración del proxy y los gotchas de arriba aplican a ambos.
En resumen
Java más proxies residenciales es sólido una vez que respetas las reglas de cada cliente: suministra las credenciales del proxy a través del mecanismo apropiado (el proxyAuthenticator de OkHttp, el CredentialsProvider de Apache), nunca en línea en una URL; comparte un único cliente de larga vida y deriva variantes de identidad de él; sube el límite del pool por-ruta de Apache para que coincida con tu concurrencia; consume y cierra siempre las respuestas para que las conexiones vuelvan al pool; y separa los timeouts de connect y de respuesta. Varía el nombre de usuario del proxy para cambiar geo o sesión, y acota la concurrencia por host.
Acierta eso y ambos clientes rinden como la JVM debería. Apúntalos al gateway residencial, y recuerda que la calidad del pool decide con qué frecuencia estás reintentando siquiera (reputación de IP). La página de precios tiene los planes por GB para probarlo contra tus propios objetivos.