Your launchd agent can't reach the LAN, and macOS won't tell you why
You have a scheduled job — a LaunchAgent — that talks to something on your own network. A device, a local API, a printer. It fails every time with something like:
httpx.ConnectError: All connection attempts failed
You run the identical code from a terminal. It works instantly. Same machine, same user, same Python, same target. The scheduled copy cannot reach a machine sitting three feet away, and the interactive copy can.
The answer
macOS Local Network permission is granted per binary, and a launchd-spawned process can never show the consent prompt — so it is silently denied.
There is no error saying "permission denied". There is no entry in the log. The connection simply never completes, and it looks exactly like the host is down.
The diagnostic that proves it in thirty seconds
From inside the same failing service, shell out to curl against the same URL.
subprocess.run(["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", url])
If curl returns 200 while your HTTP client fails, you are done diagnosing. Same process tree,
same parent, same environment, same network — different result, because /usr/bin/curl is a
separate binary with its own permission grant. Your Python interpreter has no grant and never
will, because nothing ever asked.
Rule out the theory you're about to waste an hour on
You will assume this is mDNS. It looks like mDNS: a .local hostname that won't resolve is the
classic macOS networking headache, and Bonjour is genuinely flaky.
Test the raw IP address. If the IP fails too, name resolution is not your problem and every
minute spent on Bonjour, dns-sd, or /etc/hosts is wasted. That single test is what turned this
from a day of guessing into a diagnosis.
Two more signals worth noting, because they make it more confusing rather than less:
- Internet egress works fine. The job can reach any public API. Only local subnet sockets fail.
- VPN and mesh-network traffic works fine. Tailscale, WireGuard and similar route over a virtual interface that isn't subject to the same check.
So the job looks healthy in every way except the one thing it exists to do.
Fixes, in order of how much you'll like them
1. Shell out to curl. Ugly, reliable, five minutes. Catch the connection error and retry via a
curl subprocess. This is what I shipped, and I'd ship it again for a personal automation.
try:
r = client.get(url, timeout=5)
except (httpx.ConnectError, httpx.ConnectTimeout):
r = curl_fallback(url)
2. Grant Local Network access to the interpreter in System Settings → Privacy & Security → Local Network. The correct fix in principle. In practice, launchd agents frequently never appear in that list at all, because the entry is only created when a binary triggers the prompt — which a background agent cannot do. If it isn't listed, you cannot grant it.
3. Run the job from something that already has the grant. A wrapper the user has launched interactively at least once will have been prompted and approved. Less elegant, but it works.
The general lesson
Modern macOS enforces a lot of privacy boundaries per binary, at runtime, via a prompt. Any execution context that can't display a prompt — launchd, cron, a daemon, an SSH session — inherits a silent denial rather than an error.
So when background code fails at something foreground code does easily, stop debugging the network. Ask what the background context is not allowed to be asked. That reframe generalises well beyond this specific bug: microphone, camera, screen recording, Full Disk Access and Automation all behave the same way.
Related notes
- A TLS handshake failure usually means the service was never provisioned
TLS errors against a managed cloud endpoint are rarely certificate problems. Usually the subdomain was never provisioned for your account. - Sieve rules don't run on imported or fetched mail
Your Sieve filter is correct and still does nothing. Sieve fires on delivery — mail pulled from another provider bypasses it entirely. - Cloudflare Pages returns 200 for pages that don't exist
Without a 404.html, Pages serves your homepage with HTTP 200 for every unknown URL. Google reads that as an infinite site of duplicates. One file fixes it.