Python Force IPv4 in Requests, aiohttp, httpx and ccxt
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'.
- 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 firstWhich 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.
| Client | Fix | Scope | Trade-off |
|---|---|---|---|
| requests / urllib3 | HAS_IPV6 = False or patch allowed_gai_family | Process-wide | One line, but global; HAS_IPV6 is an internal attribute |
| requests | HTTPAdapter with source_address=('0.0.0.0', 0) | Per session | No global state; needs proxy handling and a test per urllib3 version |
| aiohttp | TCPConnector(family=socket.AF_INET) | Per connector | Must be created inside a running loop |
| httpx | HTTPTransport(local_address='0.0.0.0') | Per client | Replaces the default transport, so set proxies on it |
| ccxt sync | exchange.session = ipv4 session | Per exchange | Relies on ccxt using the session you give it |
| ccxt async | aiohttp session with an AF_INET connector | Per exchange | You must close the session yourself |
| websockets | connect(uri, family=socket.AF_INET) | Per connection | Separate from the REST fix |
| Any library | Temporary socket.getaddrinfo filter | Process-wide | Blunt; also affects other threads |
| OS policy | gai.conf, prefixpolicy, DisabledComponents | Whole machine | Changes 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)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()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.
- Run socket.getaddrinfo on the target host and note whether AAAA results appear first.
- Request api64.ipify.org through your configured client and confirm the answer has no colon.
- Confirm that this IPv4 address is the one in the exchange's key restriction.
- 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.
| Method | Command or setting | Downside |
|---|---|---|
| Windows prefix policy | netsh interface ipv6 set prefixpolicy ::ffff:0:0/96 100 4 | Needs admin rights; resets can undo it |
| Windows registry | DisabledComponents = 0x20 under HKLM\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters, then reboot | Machine-wide; wrong values can break IPv6-dependent features |
| Linux gai.conf | precedence ::ffff:0:0/96 100 in /etc/gai.conf | Affects only libc resolution; static Python builds may differ |
| Linux sysctl | sysctl -w net.ipv6.conf.all.disable_ipv6=1 | Disables 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 onlyWhat 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.