Hostname → IP resolution that works around BEAM's broken DNS path on iOS and physical Android devices.
Why this exists
BEAM resolves hostnames by spawning an external helper called
inet_gethost (a port program). On macOS, Linux, Windows that
works fine. On mobile it doesn't, for two distinct reasons:
- iOS — the app sandbox forbids
execveof any binary the app didn't get a special pass for.inet_gethostnever runs. - Physical Android —
inet_gethostdoes run (mob ships it aslibinet_gethost.so, allowed toexecveby theapk_data_fileSELinux label), but Bionic's getaddrinfo from the execve'd child process returns:nxdomainfor hostnames the same app's in-process HTTPS stack resolves fine. The Android emulator happens not to hit this (its DNS proxy at10.0.2.3is reachable to anything), so the fault doesn't show in the simulator. Confirmed on a Moto G Power 5G 2024 (Android 14): app-uid TCP-by-IP succeeds, but BEAM's:inet.getaddr/2fails with:nxdomain.
In either case :inet.getaddr/2 (and therefore Req, Finch, Mint,
ReqLLM, and basically every Elixir HTTP library) fails the moment
a request hits a hostname rather than a literal IP.
This module side-steps the problem by calling the OS resolver
(getaddrinfo) in-process via a NIF — same address space, same
uid as the rest of the app — then seeding :inet_db with the result
so subsequent BEAM-level lookups for the same host succeed from the
in-process file table.
How to use it
Robust everywhere, incl. cellular: resolve/1 · preresolve/1
resolve/1 calls the OS's getaddrinfo via a NIF — Darwin's on iOS,
Bionic's on Android — then seeds :inet_db's :file table, so
subsequent :inet.getaddr/2 lookups (Req / Finch / Mint) find the
result. Because it uses the OS resolver, it works wherever the OS
does — including iOS cellular (carrier's DNS) and physical
Android devices where the forked inet_gethost path is broken.
This is the recommended path on both platforms.
Preresolve your known hosts at startup — that's all most apps need:
def on_start do
Mob.DNS.preresolve(["api.example.com", "cdn.example.com"])
# …rest of startup…
endFor a host not known until request time, call resolve/1 just
before the request. Idempotent and cheap.
General fallback (WiFi-friendly): configure_pure_beam/1
Flips the lookup chain to [:file, :dns] and seeds nameservers so
any hostname resolves via raw DNS from inside BEAM — useful when
you can't enumerate hosts up front:
def on_start do
if :mob_nif.platform() == :ios, do: Mob.DNS.configure_pure_beam()
endTwo caveats that make this the fallback, not the default:
- Don't reset the chain to include
:nativeon iOS — exec'inginet_gethostthere is fatal, it crashes the BEAM. On physical Android:nativeis non-fatal but unreliable (returns:nxdomainfor hostnames that do resolve in-process);Mob.DNS.resolve/1is the recommended path there too. - It can't resolve on cellular by default. Its default
nameservers are public (Google / Cloudflare), which carriers
commonly block →
:nxdomain. iOS exposes no reliable API to read the carrier's resolvers, so there's nothing dependable to seed instead. On cellular, preferpreresolve/resolveabove, or pass:nameserversyou know are reachable.
The two compose: :file is consulted before :dns, so anything you
resolve/1 wins over the configure_pure_beam fallback.
Scope and limitations
- IPv4 only. Most cloud endpoints serve A records; IPv6 is a follow-up if it becomes useful.
- One IP per host. If the hostname has multiple A records, the first one is used. BEAM caches the result; failover isn't automatic. If your endpoint cycles IPs frequently you may need to re-resolve.
- No automatic refresh. Mappings stay in
:inet_dbuntil the BEAM exits. If a backend's IP changes mid-session, the cached entry will be stale — callresolve/1again to refresh. - Doesn't help raw NIF networking. If a third-party NIF calls
libc
getaddrinfoitself, it never goes through BEAM's DNS layer and doesn't need (or benefit from) this fix — it already works. Only:inet-mediated lookups (which covers almost all Elixir HTTP libraries) need our help. - Background-app network restrictions still apply. Android's App Standby / battery optimizer can block all outbound network from a backgrounded app, including TCP-by-IP — resolving a name won't help if the OS is silently dropping the connect(). Use a foreground service or keep the app foregrounded for sustained DNS / connectivity.
- Host dev (Mac, Linux) doesn't need this. The NIF isn't
loaded off-device; callers get
{:error, :nif_not_loaded}and should fall back to BEAM's normal path (which works on dev).
Errors
{:ok, {a, b, c, d}} # success
{:error, :badarg} # host arg invalid
{:error, :nxdomain} # no such hostname
{:error, :timeout} # resolver TRY_AGAIN
{:error, :no_address} # resolved but no IPv4
{:error, {:gai, code}} # raw getaddrinfo error code
{:error, :nif_not_loaded} # called off-device (host tests)
Summary
Types
The error shapes resolve/1 can return.
Hostname to resolve. Latin-1 only — we're not in a domain that uses IDN.
Functions
Configure BEAM's DNS path so :inet.getaddr/2 (and Req / Finch /
Mint / gen_tcp:connect/3 with a hostname) works without per-host
setup.
Resolve a list of hostnames. Returns a map of host → result so the caller can see which ones failed.
Resolve host to an IPv4 address and seed :inet_db so subsequent
:inet.getaddr/2 lookups (and thus Req / Finch / Mint) find it.
True when host is already seeded in :inet_db.
Types
Functions
@spec configure_pure_beam([{:nameservers, [:inet.ip_address()]}]) :: :ok
Configure BEAM's DNS path so :inet.getaddr/2 (and Req / Finch /
Mint / gen_tcp:connect/3 with a hostname) works without per-host
setup.
Sets the lookup chain to [:file, :dns] and seeds fallback
nameservers. Both ops are idempotent.
Why
BEAM's default :native lookup spawns inet_gethost, which iOS
refuses to execve. The :dns lookup, by contrast, performs raw
UDP/TCP DNS queries from inside BEAM via gen_udp / gen_tcp —
no port program, no fork. iOS doesn't block sockets, so the :dns
path Just Works.
After calling this, the whole :inet-mediated HTTP stack stops
needing a per-host resolve/1 call. :file stays first in the
chain so any host you do resolve/1 manually still wins — the two
paths compose.
When NOT to default to this
Reach for per-host resolve/1 (which uses libc getaddrinfo via
the NIF, going through Apple's resolver) when you need any of:
- VPN-pushed DNS for internal hostnames
.local/ mDNS service discovery- Search-domain expansion (single-label hostnames like
https://api/) - Captive-portal-aware lookup
- Encrypted-DNS-at-OS-level (DoH / DoT configured in iOS Settings)
These all require Apple's resolver, which only the NIF path
consults. The pure-BEAM :dns path queries whatever nameservers
you seed and nothing else.
Cellular caveat
This won't resolve on cellular with the defaults: the seeded public
resolvers (8.8.8.8 / 1.1.1.1) are commonly blocked by carriers →
:nxdomain. iOS exposes no reliable API to read the carrier's
resolvers, so there's nothing dependable to seed instead. For hosts
you can name, prefer preresolve/1 / resolve/1 (they use the OS
resolver and work on cellular); otherwise pass :nameservers you
know are reachable.
Opts
:nameservers— list of nameserver IP tuples (IPv4 or IPv6). Defaults to[{8, 8, 8, 8}, {1, 1, 1, 1}](Google + Cloudflare). Pass any list, including[]to skip seeding (e.g. if your app's:kernelenv already configures them). Common alternatives:[{9, 9, 9, 9}]— Quad9 (privacy-leaning, no logging)[{10, 0, 0, 1}, {10, 0, 0, 2}]— your corporate resolvers
Idempotent
Calling this twice is a no-op on the second call — duplicate nameservers aren't added, the lookup chain isn't reordered.
Examples
# Default — most apps need nothing more
Mob.DNS.configure_pure_beam()
# Override the fallback nameservers
Mob.DNS.configure_pure_beam(nameservers: [{9, 9, 9, 9}])
# Set the lookup chain but skip nameserver seeding
Mob.DNS.configure_pure_beam(nameservers: [])
@spec preresolve([host()]) :: %{ required(host()) => {:ok, :inet.ip4_address()} | {:error, error_reason()} }
Resolve a list of hostnames. Returns a map of host → result so the caller can see which ones failed.
Useful at app startup for the known-fixed set of backends your app talks to.
%{
"api.example.com" => {:ok, {93, 184, 216, 34}},
"auth.example.com" => {:error, :nxdomain}
}
@spec resolve(host()) :: {:ok, :inet.ip4_address()} | {:error, error_reason()}
Resolve host to an IPv4 address and seed :inet_db so subsequent
:inet.getaddr/2 lookups (and thus Req / Finch / Mint) find it.
Idempotent — calling for the same host twice is harmless.
See module doc for usage, scope, and error shapes.
True when host is already seeded in :inet_db.
Useful for short-circuiting in caller code that wants to avoid an
unnecessary NIF call — but resolve/1 is idempotent, so calling
it again is also fine.