cdpfleet Docs GitHub Dashboard

Cases / #7 · 2026-09-30 · Medium

Different sites, different proxies, one browser: proxy_rules

Route one host through a SOCKS5 proxy, another through a round-robin pool and everything else through residential — plus the hosts a rule can't move.

Chromium

Run on production on 2026-09-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

Residential bandwidth is expensive; datacenter proxies are cheap but get blocked on the sites that matter. A common pattern: send the protected site through residential, static assets or APIs through cheap proxies. proxy_rules does this inside one browser — let's check each rule really exits where it should.

What we used, and why

WhatWhy
proxyThe default route: every host no rule matches (residential here).
proxy_rules rule 1*.ident.me and ident.me → one SOCKS5 proxy. Host patterns take * wildcards.
proxy_rules rule 2httpbin.org → a list of two proxies, used round-robin per connection.
proxy_rules rule 3api.ipify.org → a SOCKS5 proxy. It won't apply, on purpose — see below.
A new context per readingEach context has its own connection pool, so every reading is a new connection and the round-robin shows.

How it works

  1. Launch with a default proxy and three rules.
  2. Read the exit IP from each echo service, each time in a fresh context (one retry if a proxy drops the connection).
  3. Compare each exit with the proxy its rule names.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL, SOCKS_PROXIES (comma-separated socks5:// URLs, 3 or more)
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const [socksA, socksB, socksC] = process.env.SOCKS_PROXIES.split(',');
const hostOf = (url) => new URL(url.replace(/^socks5h?:/, 'http:')).hostname;

const options = {
  proxy: process.env.PROXY_URL, // everything not matched below
  proxy_rules: [
    { hosts: ['*.ident.me', 'ident.me'], proxy: socksA },
    { hosts: ['httpbin.org', '*.httpbin.org'], proxy: [socksB, socksC] }, // round-robin
    // IP-lookup services always use the default proxy, so this rule is ignored on purpose.
    { hosts: ['api.ipify.org'], proxy: socksA },
  ],
  headless: true,
};

const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
  method: 'POST',
  headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
  body: JSON.stringify(options),
});
if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });

// Each context has its own connection pool, so each reading is a fresh connection.
// Proxies drop a connection now and then: one retry.
async function exitIpVia(url) {
  for (let attempt = 1; ; attempt++) {
    const context = await browser.newContext();
    try {
      const page = await context.newPage();
      const text = await (await page.goto(url, { timeout: 30000 })).text();
      return text.match(/\d{1,3}(\.\d{1,3}){3}/)?.[0] ?? `(no IP in ${url})`;
    } catch (err) {
      if (attempt === 2) return `(failed: ${err.message.split('\n')[0]})`;
    } finally {
      await context.close();
    }
  }
}

try {
  const readings = [];
  for (const url of ['https://v4.ident.me/', 'https://httpbin.org/ip', 'https://httpbin.org/ip', 'https://httpbin.org/ip', 'https://api.ipify.org/', 'https://www.cloudflare.com/cdn-cgi/trace']) {
    readings.push({ url, exit_ip: await exitIpVia(url) });
  }
  console.log(JSON.stringify({
    rules: {
      '*.ident.me': hostOf(socksA),
      'httpbin.org': [hostOf(socksB), hostOf(socksC)],
      'api.ipify.org': `${hostOf(socksA)} (ignored: IP-lookup host)`,
      '(everything else)': 'residential PROXY_URL',
    },
    readings,
  }, null, 2));
} finally {
  await browser.close();
}

What we got

URLExit IPRule
https://v4.ident.me/203.0.113.1203.0.113.1
https://httpbin.org/ip203.0.113.2203.0.113.2 or 203.0.113.3
https://httpbin.org/ip203.0.113.3203.0.113.2 or 203.0.113.3
https://httpbin.org/ip203.0.113.2203.0.113.2 or 203.0.113.3
https://api.ipify.org/203.0.113.4default proxy (IP-lookup host)
https://www.cloudflare.com/cdn-cgi/trace203.0.113.5default proxy

From the Node.js run on 2026-09-30. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
{
  "rules": {
    "*.ident.me": "203.0.113.1",
    "httpbin.org": [
      "203.0.113.2",
      "203.0.113.3"
    ],
    "api.ipify.org": "203.0.113.1 (ignored: IP-lookup host)",
    "(everything else)": "residential PROXY_URL"
  },
  "readings": [
    {
      "url": "https://v4.ident.me/",
      "exit_ip": "203.0.113.1"
    },
    {
      "url": "https://httpbin.org/ip",
      "exit_ip": "203.0.113.2"
    },
    {
      "url": "https://httpbin.org/ip",
      "exit_ip": "203.0.113.3"
    },
    {
      "url": "https://httpbin.org/ip",
      "exit_ip": "203.0.113.2"
    },
    {
      "url": "https://api.ipify.org/",
      "exit_ip": "203.0.113.4"
    },
    {
      "url": "https://www.cloudflare.com/cdn-cgi/trace",
      "exit_ip": "203.0.113.5"
    }
  ]
}

Takeaways