Howwise RSS
Trading Software7 min read

Python Force IPv4 in Requests, aiohttp, httpx and ccxt

Short answer

To make Python force IPv4 in requests, set urllib3.util.connection.HAS_IPV6 = False, or mount an HTTPAdapter that binds source_address to ('0.0.0.0', 0). aiohttp uses family=AF_INET and httpx uses local_address='0.0.0.0'.

Key takeaways
  • Requests goes out over IPv6 because getaddrinfo returns AAAA records and the OS address-selection policy (RFC 6724) sorts them first, so a whitelist that holds only your IPv4 address rejects the call.
  • For requests, a per-session HTTPAdapter with source_address=('0.0.0.0', 0) avoids global state, while patching urllib3's allowed_gai_family is the stable process-wide option.
  • aiohttp needs TCPConnector(family=socket.AF_INET), httpx needs HTTPTransport(local_address='0.0.0.0'), and websockets needs family=socket.AF_INET, each applied separately.
  • Verify with api.ipify.org (IPv4 only) against api64.ipify.org (dual-stack) and by inspecting the connected socket's family, not by assuming the patch worked.
  • Connecting to a hard-coded IPv4 literal breaks SNI, certificate checks and CDN routing, so force the address family instead.

Why do requests go out over IPv6 and break an IP whitelist?

When a client connects to api.binance.com, it calls getaddrinfo, which returns both AAAA (IPv6) and A (IPv4) records. The OS then sorts that list using its address-selection policy (RFC 6724). A machine with a global IPv6 address usually ranks IPv6 destinations above IPv4, and Windows follows this strongly. Python simply tries the first result.

Exchanges and brokers that restrict an API key by IP compare the source address they see with the list you saved. If you whitelisted your IPv4 address but the connection arrives from your IPv6 address, the server sees an unlisted client. Binance reports this as error -2015, 'Invalid API-key, IP, or permissions for action'. The message is the same one used for a wrong key or missing permission, so it is easy to misdiagnose.

The cleanest fix is to restrict the address family in code, so the A record is used and your whitelisted IPv4 address is the source. The alternative is to add your IPv6 address (or prefix) to the key's restriction list. That is fragile because many ISPs rotate IPv6 prefixes and privacy extensions rotate the host part.

import socket
for fam, _, _, _, addr in socket.getaddrinfo('api.binance.com', 443, type=socket.SOCK_STREAM):
    print(fam.name, addr[0])
# AF_INET6 entries listed first = the OS will try IPv6 first
Tip: Whatever fix you pick, also confirm that the API key's IP restriction contains the IPv4 address your egress actually uses. That is often your router's public address or your VPS address, not a LAN address.

Which fix should I use for each library?

Every library resolves and connects differently, so a patch for one does not touch another. This table maps each client to the narrowest fix and its main trade-off.

ClientFixScopeTrade-off
requests / urllib3HAS_IPV6 = False or patch allowed_gai_familyProcess-wideOne line, but global; HAS_IPV6 is an internal attribute
requestsHTTPAdapter with source_address=('0.0.0.0', 0)Per sessionNo global state; needs proxy handling and a test per urllib3 version
aiohttpTCPConnector(family=socket.AF_INET)Per connectorMust be created inside a running loop
httpxHTTPTransport(local_address='0.0.0.0')Per clientReplaces the default transport, so set proxies on it
ccxt syncexchange.session = ipv4 sessionPer exchangeRelies on ccxt using the session you give it
ccxt asyncaiohttp session with an AF_INET connectorPer exchangeYou must close the session yourself
websocketsconnect(uri, family=socket.AF_INET)Per connectionSeparate from the REST fix
Any libraryTemporary socket.getaddrinfo filterProcess-wideBlunt; also affects other threads
OS policygai.conf, prefixpolicy, DisabledComponentsWhole machineChanges behaviour for all programs

How do I force IPv4 in requests and urllib3?

The shortest fix flips a flag that urllib3 reads whenever it opens a connection. Inside urllib3, allowed_gai_family() returns AF_INET when HAS_IPV6 is false and AF_UNSPEC otherwise. It is global and affects every urllib3 user in the process, including other libraries. It also depends on a module attribute that is not part of the public API.

Patching the function is more stable, because the function is the single decision point and the patch states your intent directly. Apply either one before any connection is created or pooled, since open keep-alive connections keep their old socket.

import socket
import urllib3.util.connection as urllib3_cn

# Option A: one line, relies on an internal flag
urllib3_cn.HAS_IPV6 = False

# Option B: patch the decision function (preferred global variant)
urllib3_cn.allowed_gai_family = lambda: socket.AF_INET

import requests
print(requests.get('https://api64.ipify.org').text)  # should be IPv4

How do I force IPv4 for one requests Session without global state?

An HTTPAdapter can pass source_address to the pool manager. Binding the socket to ('0.0.0.0', 0) means 'any IPv4 interface, any port'. A socket created for an IPv6 address cannot bind to an IPv4 wildcard, so urllib3 treats that attempt as failed and moves on to the next getaddrinfo result, which is the IPv4 one.

This works in both urllib3 1.x and 2.x, because create_connection catches OSError for each candidate address. Still, test it on your versions, as shown in the verification section. Proxy traffic uses a separate pool manager, so the helper overrides that as well.

import requests
from requests.adapters import HTTPAdapter

class IPv4Adapter(HTTPAdapter):
    def init_poolmanager(self, connections, maxsize, block=False, **kw):
        kw['source_address'] = ('0.0.0.0', 0)
        super().init_poolmanager(connections, maxsize, block=block, **kw)

    def proxy_manager_for(self, proxy, **kw):
        kw['source_address'] = ('0.0.0.0', 0)
        return super().proxy_manager_for(proxy, **kw)

def make_ipv4_session(force=True):
    """Tested pattern for requests 2.x with urllib3 1.26 or 2.x."""
    s = requests.Session()
    if force:
        a = IPv4Adapter()
        s.mount('http://', a)
        s.mount('https://', a)
    return s

s = make_ipv4_session()
print(s.get('https://api64.ipify.org', timeout=10).text)
Tip: The force flag lets a config option switch the behaviour off, which matters on hosts that only have an IPv6 route (see the pitfalls section).

How do I force IPv4 in aiohttp, httpx, websockets and ccxt?

Each async or modern client has its own connection layer, so the urllib3 patches do nothing here. Pass the family explicitly. For aiohttp, create the connector inside a running event loop. Setting family=AF_INET also removes the Happy Eyeballs race for IPv6, because only IPv4 addresses are resolved.

For ccxt, the library uses a requests.Session in sync mode and an aiohttp session in async mode. Handing it a pre-built session is the least invasive route. Keep ccxt's own session handling in mind: with a session you supplied, you are responsible for closing it.

import socket, asyncio, aiohttp, httpx, websockets

# aiohttp
async def aio():
    conn = aiohttp.TCPConnector(family=socket.AF_INET)
    async with aiohttp.ClientSession(connector=conn) as s:
        async with s.get('https://api64.ipify.org') as r:
            print(await r.text())

# httpx (sync and async)
with httpx.Client(transport=httpx.HTTPTransport(local_address='0.0.0.0')) as c:
    print(c.get('https://api64.ipify.org').text)

async def hx():
    t = httpx.AsyncHTTPTransport(local_address='0.0.0.0')
    async with httpx.AsyncClient(transport=t) as c:
        print((await c.get('https://api64.ipify.org')).text)

# websockets: kwargs reach loop.create_connection
async def ws():
    async with websockets.connect('wss://stream.binance.com:9443/ws/btcusdt@trade',
                                  family=socket.AF_INET) as w:
        print(await w.recv())

# ccxt sync
import ccxt
ex = ccxt.binance({'apiKey': 'KEY', 'secret': 'SECRET'})
ex.session = make_ipv4_session()   # from the helper above

# ccxt async
import ccxt.async_support as ccxta
async def ccxt_async():
    conn = aiohttp.TCPConnector(family=socket.AF_INET)
    sess = aiohttp.ClientSession(connector=conn)
    ex = ccxta.binance({'apiKey': 'KEY', 'secret': 'SECRET', 'session': sess})
    try:
        print(await ex.fetch_balance())
    finally:
        await ex.close()
        await sess.close()
Warning: Never paste real API keys into scripts you share or commit. Load them from environment variables, and keep withdrawal permission off keys used for testing.

How do I verify which IP family is really in use?

Do not trust that a patch worked because the call succeeded. Compare two endpoints. api.ipify.org has only an A record and always reports IPv4. api64.ipify.org is dual-stack and reports whichever family you connected with. An address containing a colon is IPv6.

The most direct proof is the connected socket. With asyncio you can read it from the transport. With requests, stream the response so the connection stays attached, then call getpeername(). The attribute path touches urllib3 internals, so use it for diagnostics only.

  1. Run socket.getaddrinfo on the target host and note whether AAAA results appear first.
  2. Request api64.ipify.org through your configured client and confirm the answer has no colon.
  3. Confirm that this IPv4 address is the one in the exchange's key restriction.
  4. Inspect the socket family if you need proof beyond the printed address.
r = make_ipv4_session().get('https://api.binance.com/api/v3/ping', stream=True)
sock = r.raw._connection.sock          # internal attribute, diagnostics only
print(sock.family.name, sock.getpeername())   # expect AF_INET
r.close()

What are the global monkeypatch and OS-level alternatives?

Patching socket.getaddrinfo works for any library that resolves names through it, even code that bypasses urllib3. It is blunt, because every thread and library in the process is affected while the patch is active. A context manager that restores the original keeps the damage small.

OS-level changes need no code but alter behaviour for every program on the machine, and they can be reverted by updates or by someone else's configuration. Code-level fixes travel with the project to other machines, containers and CI.

MethodCommand or settingDownside
Windows prefix policynetsh interface ipv6 set prefixpolicy ::ffff:0:0/96 100 4Needs admin rights; resets can undo it
Windows registryDisabledComponents = 0x20 under HKLM\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters, then rebootMachine-wide; wrong values can break IPv6-dependent features
Linux gai.confprecedence ::ffff:0:0/96 100 in /etc/gai.confAffects only libc resolution; static Python builds may differ
Linux sysctlsysctl -w net.ipv6.conf.all.disable_ipv6=1Disables IPv6 for every service on the host
import socket
from contextlib import contextmanager

@contextmanager
def ipv4_only():
    orig = socket.getaddrinfo
    def patched(host, port, family=0, *a, **kw):
        return orig(host, port, socket.AF_INET, *a, **kw)
    socket.getaddrinfo = patched
    try:
        yield
    finally:
        socket.getaddrinfo = orig

with ipv4_only():
    ...  # any library call here resolves A records only
Warning: Edit the registry or run sysctl only if you understand the effect. Fully disabling IPv6 on Windows (0xFF) is discouraged and can cause side effects.

What pitfalls break an IPv4 fix, and why not just use the IP address?

Connecting to a literal IPv4 address, or using a custom DNS resolver, looks simple but is fragile. TLS needs the hostname for SNI and certificate verification, and the Host header must match the virtual host. Exchanges sit behind CDNs whose IPs change, so a hard-coded address eventually points at the wrong place. Forcing the family keeps normal DNS, SNI and certificate checks intact.

Other causes of surprises: a proxy or VPN changes the egress IP, so the whitelist must contain the proxy's address instead. Happy Eyeballs (RFC 8305), used by asyncio and aiohttp via happy_eyeballs_delay, can still race IPv6 first if you leave the family unset. A fix applied after a session already holds pooled connections does not affect them, so build the session first. WSL and containers have their own network stack and may egress through a different address than the host.

The last risk is a host with only an IPv6 route. After forcing IPv4, every call fails with a connection or name resolution error. Keep the setting behind a config flag and fall back deliberately.

import os, requests
FORCE_V4 = os.getenv('FORCE_IPV4', '1') == '1'
s = make_ipv4_session(force=FORCE_V4)
try:
    s.get('https://api.binance.com/api/v3/ping', timeout=10)
except requests.ConnectionError:
    if FORCE_V4:
        raise RuntimeError('IPv4 failed; host may be IPv6-only, set FORCE_IPV4=0')
    raise

Common questions

Does HAS_IPV6 = False work on urllib3 2.x?

Yes, allowed_gai_family() still reads it in current urllib3 releases. Because it is an internal attribute, patching allowed_gai_family itself is safer if you want to survive refactors.

Why does my aiohttp request still use IPv6 after I set a proxy?

With a proxy, the client resolves and connects to the proxy, and the proxy then connects to the target. The exchange sees the proxy's egress address, so whitelist that address and confirm its family.

Will binding to 0.0.0.0 break connections on a multi-homed machine?

No. Port 0 and the wildcard address let the OS pick the interface and port by routing, as it normally would. Only the address family is restricted.

Do I need to restart anything after changing the Windows prefix policy?

The prefixpolicy change applies to new resolutions, but long-running Python processes should be restarted. The DisabledComponents registry value requires a reboot.

Can I whitelist the IPv6 address instead?

Some exchanges accept IPv6 or ranges, but home and mobile IPv6 prefixes often rotate, which silently breaks the whitelist. A stable IPv4 egress is usually easier to keep correct.

Related topics:pythonipv4requestsccxtbinance