Compatibility — servers, task queues, client libraries¶
What tethered enforces under the servers, task queues and client libraries people deploy, what needs a particular configuration, what tethered refuses at start-up, and what it does not see at all. Everything on this page was measured: each row names the container suite in tests_containers/ that asserts it, and the versions it was measured with. The cookbook has the configuration for each server; the security model has the limits as part of the threat model.
How to read this page¶
What was measured. Each suite builds a wheel of tethered from the source tree, installs it into the official python:X.Y-slim Docker image the way a user installs it, starts the real server or library, and asks it to reach four destinations: a host the policy allows (allowed.test:9001), a forbidden name, a forbidden raw address, and the allowed name on a port the rule does not cover. The destinations count every connection and datagram that arrives. "Refused" on this page means that nothing arrived, not only that the application saw an exception. Every suite runs on Python 3.11, 3.12, 3.13, 3.14 and 3.15, on Linux x86-64 with glibc.
The results.
| Result | Meaning |
|---|---|
| Enforced | The forbidden name, the forbidden address and the other port are refused and nothing arrives; the allowed destination is reached. Under locked=True the result is the same. |
| Needs configuration | Enforced once the setting named in the row is in place. Without it the arrangement ends in one of the results below. |
| Refused at start-up | tethered does not run in this arrangement, and says so: activate() raises RuntimeError, or an import raises ImportError, and the server does not start. Nothing is served without the policy. |
| Never serves | The same refusal inside a worker that a supervisor keeps replacing: the server process stays up and answers no request. |
| Allowed host refused | Nothing forbidden is reached, but a host the allow list names is refused as well, because the library looked the name up where tethered could not see it. The forbidden name is still sent to the name server. |
| Partly enforced | The name is checked; the connection is made in C, so a raw address or a port outside the rule is reached. |
| Not seen | Neither refused nor reported. See the next section. |
The versions. Every table names the release it was measured with. A row holds for that release; where a behaviour depends on the release (uvicorn's does), the row says so. To run a suite yourself: uv run python tests_containers/run.py --suite uvicorn --python 3.13.
Measured once. A few statements come from single runs made while the suites were written and are not asserted by a test. They are marked "measured once".
What "not seen" means¶
tethered is an audit hook. CPython raises an audit event when Python code calls into the socket module (socket.getaddrinfo, socket.connect, socket.sendto) or launches a process (subprocess.Popen, os.exec*), and tethered's hook decides at that event. The hook is called by the interpreter. It cannot add an event where the interpreter raises none.
A library that resolves names and opens connections inside its own compiled code never calls Python's socket module. libcurl, libpq, the gRPC core, librdkafka and the libuv loop inside uvloop are examples. No event is raised, the hook is not called, and tethered has nothing to decide. For such a library, under any policy:
- the forbidden name is looked up, and the forbidden address and port are reached;
- no exception is raised and no log line is written;
locked=Trueandfail_closed=Truechange nothing, because they act on events that never come;log_only=Truereports nothing, so a log-only rollout does not reveal these connections either;on_blockedis not called and ascope()does not apply.
No setting of tethered changes this, and no later version of tethered can, for as long as it works through audit events. Where tethered knows of a common case it refuses to run instead of running unenforced: it keeps uvloop and pycares from being imported under a policy (below). For everything else on this page that is "not seen", the remedy is a pure-Python driver where one exists (the tables name it), or a control below Python: a network policy, a firewall rule, seccomp.
Event loops and green threads¶
| Runtime | Result | Detail | Pinned by |
|---|---|---|---|
| asyncio's own selector event loop (the default on Linux) | Enforced | Streams, loop.create_connection, loop.sock_connect, loop.getaddrinfo, connected and unconnected datagram endpoints, asyncio.to_thread, and asyncio.create_subprocess_exec / create_subprocess_shell. |
uvicorn and every other asynchronous server suite |
| uvloop | Refused at start-up | With a policy active that is not log_only, import uvloop raises ImportError; with uvloop already loaded, activate() raises RuntimeError. A server that takes uvloop only when it can be imported runs on the standard loop instead. Four cases still end on uvloop, see below. |
uvicorn, uvicorn_legacy, gunicorn_uvicorn, fastapi, hypercorn, granian, sanic, aiohttp, modwsgi (uvloop 0.23.0) |
| rloop | Enforced, with one gap | Connections, lookups and launches go through Python's socket and subprocess modules. transport.sendto(data, addr) on an unconnected datagram endpoint is sent below Python and is not seen. |
granian (rloop 0.5.0) |
| trio | Enforced | trio uses Python's socket and subprocess modules. trio.open_tcp_stream delivers a refused connection inside an ExceptionGroup; a refused lookup arrives bare. |
hypercorn, clients (trio 0.34.0) |
| Twisted | Enforced | Nothing forbidden gets out. A refusal does not reach the caller as EgressBlocked: see daphne and Twisted. |
daphne, clients (Twisted 26.4.0) |
| tornado's IOLoop | Enforced | It is asyncio's selector loop. | tornado, gunicorn_tornado (tornado 6.5.10) |
gevent, its default resolver (a thread pool) or GEVENT_RESOLVER=block |
Enforced | One hazard with the thread-pool resolver: a refused lookup now and then never returns. See gunicorn. | gunicorn_gevent, clients, celery (gevent 26.9.0) |
gevent with GEVENT_RESOLVER=ares |
Allowed host refused | c-ares resolves in C. | gunicorn_gevent, clients |
| eventlet, its default resolver (greendns) | Allowed host refused | greendns is a DNS client of its own (dnspython) and raises no socket.getaddrinfo event. |
clients, celery (eventlet 0.41.2) |
eventlet with EVENTLET_NO_GREENDNS=yes |
Enforced | clients, celery |
|
asyncio's ProactorEventLoop (the Windows default) |
Not tested here | That loop raises no connect events, so there is no connect-time enforcement on it: see the security model. |
Where a process still ends on uvloop. The refusal works where a policy that enforces is active in the interpreter that imports uvloop. It does not cover:
- A
log_onlypolicy. It refuses nothing, the import of uvloop included. The server runs on uvloop, and nothing that goes through the loop is reported: the list of what would be blocked stays empty for every asynchronous call. tethered logs one warning (tethered: uvloop is loaded in this process.). Code that uses thesocketmodule itself is still reported. Run a log-only rollout of an asyncio application with the loop named (uvicorn --loop asyncio). Pinned byuvicorn,fastapi,gunicorn_uvicorn,hypercorn,granian,sanic,aiohttp. - A
scope()with noactivate()under it. A scope alone does not keep uvloop out. On uvloop,asyncio.open_connectionandloop.create_connectioninside the scope reach refused destinations;loop.sock_connect,asyncio.to_threadand blocking code are refused by the scope. One warning is logged. With--loop asyncioeverything is refused. Pinned byuvicorn. - An interpreter outside a lock. Under mod_wsgi, an application whose interpreter existed before another application locked a policy is refused every socket, but it holds no policy of its own, so it can import uvloop, and a connection on a uvloop loop then arrives at a forbidden destination. Pinned by
modwsgi. - A server that swallows the refusal. uvicorn's default
--lifespan autotakes any exception from a hand-written ASGI lifespan handler for "this application does not support lifespan". An application that activates there, after uvicorn has loaded uvloop, is served on uvloop with no policy at all. tethered logs the refusal as an error before it raises it, and that one line is all that is left. FastAPI and Starlette report a failed start-up and the server exits. Pinned byuvicorn.
Web servers¶
uvicorn¶
Measured with uvloop 0.23.0 installed, as it is with uvicorn[standard], and an application that calls activate() when it is imported. Suites: uvicorn (uvicorn 0.54.0) and uvicorn_legacy (uvicorn 0.46.0).
| Arrangement | uvicorn 0.54.0 | uvicorn 0.46.0 |
|---|---|---|
uvicorn main:app, the loop left to uvicorn |
Enforced. uvicorn imports the application first, its import of uvloop is refused, and it falls back to the standard loop on its own. | Refused at start-up: uvloop is loaded first, activate() raises RuntimeError, exit status 1. |
--loop asyncio, or UVICORN_LOOP=asyncio in the environment of the uvicorn command |
Enforced. uvloop is never asked for. | Enforced. |
--loop uvloop |
Refused at start-up (ImportError, exit status 1). |
Refused at start-up (RuntimeError, exit status 1). |
--workers N, the loop left to uvicorn |
Never serves: every worker loads uvloop before it imports the application and dies at activate(); the supervisor starts another one without end. |
Never serves. |
--workers N with --loop asyncio or UVICORN_LOOP=asyncio |
Enforced in every worker, and in a worker that replaces a killed one. | Enforced. |
--reload, the loop left to uvicorn |
Never serves. | Not tested. |
--reload --loop asyncio |
Enforced. | Not tested. |
uvicorn.run("main:app") |
Enforced on the standard loop. | Refused at start-up. uvicorn.run() does not read UVICORN_LOOP. |
uvicorn.run(app) with an application object the script imported |
Enforced. | Enforced. |
uvicorn.run("main:app", workers=2) with no loop argument |
Never serves. UVICORN_LOOP does not help. |
Not tested. |
uvicorn.run("main:app", loop="asyncio"), with or without workers=2 |
Enforced (measured once, on Python 3.13; the suites pass loop="asyncio" only together with a launcher that activates first). |
Not tested. |
A launcher that calls activate() and then uvicorn.run(...), with or without workers |
Enforced. Workers inherit the policy as they start; a worker that replaces a killed one too. | Enforced. |
uvicorn.Server(uvicorn.Config("main:app")).run() |
Refused at start-up: this route picks the loop before it imports the application. | Not tested. |
asyncio.run(uvicorn.Server(config).serve()) |
Enforced. uvloop is never asked for. | Enforced. |
--factory, with activate() inside the factory |
Refused at start-up; enforced with --loop asyncio. |
Not tested. |
activate() in a hand-written ASGI lifespan handler |
Served on uvloop with no policy under the default --lifespan auto (see above). Exit status 3 with --lifespan on. Enforced with --loop asyncio. |
Not tested. |
A log_only policy |
Runs on uvloop; what goes through the loop is not reported. | The same. |
On the standard loop the uvicorn suite covers every way out of a coroutine listed under Event loops, a response of 20 MB under a plain and a locked policy, Python children and external tools launched from a handler, and external_subprocess_policy="block".
Which releases import the application first (measured once, on Python 3.13, not asserted by a suite): uvicorn 0.47.0 is the first release whose uvicorn main:app imports the application before it chooses the loop. From 0.50.0 the worker and reload processes choose the loop first again, which is the "never serves" row; 0.49.0 with --workers 2 worked. Do not build on the order: name the loop.
FastAPI¶
Measured with fastapi 0.143.0 installed as fastapi[standard] (fastapi-cli 0.0.32, starlette 1.7.0, httpx 0.28.1 on anyio 4.15.1, uvloop 0.23.0). Suites: fastapi (uvicorn 0.54.0) and uvicorn_legacy (uvicorn 0.46.0). fastapi run has no option for the event loop, and UVICORN_LOOP does not reach it.
| Arrangement | Result | Pinned by |
|---|---|---|
fastapi run main.py, or fastapi run finding main.py, with or without --workers |
Enforced. The command imports the application itself, so the policy is active before uvicorn starts; workers inherit it. | fastapi, uvicorn_legacy |
fastapi dev main.py |
Enforced: the process the reloader starts inherits the policy. | fastapi |
fastapi run --entrypoint main:app (or the entry point in pyproject.toml) |
Enforced with uvicorn 0.54.0. Refused at start-up with uvicorn 0.46.0. | fastapi, uvicorn_legacy |
fastapi run --entrypoint main:app --workers 2 |
Never serves. Use a path, or uvicorn main:app --workers 2 --loop asyncio. |
fastapi, uvicorn_legacy |
activate() in a FastAPI lifespan handler |
Refused at start-up: "Application startup failed. Exiting.", exit status 3. Enforced with uvicorn --loop asyncio. |
fastapi |
Starlette's TestClient |
Enforced. It runs the application on the standard loop; asking anyio for uvloop (backend_options={"use_uvloop": True}) fails with tethered's ImportError. |
fastapi |
| The exception handler of the cookbook | Every refusal ends as the HTTP 503 of the recipe and nothing arrives: a refused lookup, a port rule and a raw address, for httpx.AsyncClient, httpx.Client, asyncio streams and the websockets client. |
fastapi |
Of the compiled modules a fastapi[standard] installation brings, uvloop is the only one that imports the C library's connect and getaddrinfo itself (a test of the fastapi suite).
gunicorn¶
Measured with gunicorn 26.2.0.
| Arrangement | Result | Pinned by |
|---|---|---|
sync and gthread workers, with and without --preload |
Enforced in every worker, in workers started after --max-requests, and after a SIGHUP reload. |
gunicorn |
Binary upgrade (SIGUSR2) |
Works with no pre_exec hook, with a locked or an unlocked policy, with and without --preload, and under external_subprocess_policy="block": the new master starts under the policy and its workers enforce. A client that kept requesting the TCP and the unix listener saw no failed request. |
gunicorn |
Binary upgrade into a release that asks for a different locked policy, with --preload |
The new master does not start (TetheredLocked); the old one keeps serving under the old policy. See what holds under every server. |
gunicorn |
-k uvicorn.workers.UvicornWorker and -k uvicorn_worker.UvicornWorker, with and without --preload |
Enforced on the standard loop: the worker imports the application before it asks for its loop, and the import of uvloop is refused. Replaced workers too. | gunicorn_uvicorn (uvicorn 0.54.0, uvicorn-worker 0.4.0), uvicorn_legacy (uvicorn 0.46.0) |
UvicornH11Worker |
Enforced. It names the standard loop and never asks for uvloop. | gunicorn_uvicorn |
-k asgi (gunicorn's own ASGI worker) |
Refused at start-up without --preload: the worker sets its loop up before it imports the application ("Worker failed to boot."). Enforced with --preload or --asgi-loop asyncio. |
gunicorn_uvicorn |
-k gevent and -k gevent_pywsgi, with and without --preload |
Enforced in every worker and for overlapping greenlets. See the notes below. | gunicorn_gevent (gevent 26.9.0) |
-k tornado |
Enforced. An uncaught exception gets no response at all: tornado logs it and leaves the connection open, with or without tethered. | gunicorn_tornado (tornado 6.5.10) |
-k aiohttp.GunicornWebWorker |
Enforced. | aiohttp |
-k aiohttp.GunicornUVLoopWebWorker |
Refused at start-up ("Worker failed to boot."). | aiohttp |
For gunicorn's uvicorn worker the release that changed the order is older than for the uvicorn command (measured once, not asserted by a suite): with uvicorn 0.35.0 the worker set the loop up first, and gunicorn exited with "Worker failed to boot" unless --preload was given; from 0.36.0 it imports the application first.
The gevent worker.
- Hazard: a refused lookup sometimes never returns. tethered logs a refusal in the thread in which the audit event is raised, and for a lookup under gevent's default resolver that is one of gevent's pool threads. After monkey-patching, the lock of a logging handler is gevent's, and when several pool threads log at the same moment one of them is now and then never woken. Its lookup never returns, the request hangs, and a worker that is told to restart or to stop waits for that request until gunicorn's graceful timeout (30 seconds by default). Measured on Python 3.13 with gevent 26.9.0: a stuck request in 8 of 30 stress runs of a gunicorn gevent worker (4 of 30 with
--preload, 0 of 60 without tethered), and, without gunicorn, a greenlet that never came back in 9 and 15 of 120 new processes that each made 40 refused lookups from 8 greenlets. It is not specific to tethered: an audit hook that logs and raises did the same in 7 of 120 processes, one that only raises in none. Allowed lookups and connections were never affected (0 of 360). How often it happens in a long-lived worker was not established. One mitigation was measured: with thetetheredlogger set toERROR, so that tethered does not log its warning for a refusal, 0 of 180 processes had a stuck greenlet. That is a measurement, not a guarantee, it removes tethered's own line about each refusal (the exception is still raised), and anything else that logs from the resolver's threads can trigger the same. - gevent's hub prints a traceback for every refused lookup. Adding
tethered.EgressBlockedto the hub'sNOT_ERRORsilences it. - Process launches go through gevent's own
subprocess, which forks and callsos.exec*from Python. tethered seesos.exec*events inside the forked child, one for eachPATHdirectory that is tried. A child started withsys.executableinherits the policy, with an explicit environment that lacks the policy variable too. What changes is where tethered's log lines go: the forked child writes them to its stderr, which is the pipe the application captures. A warning about an external launch (one perPATHdirectory tried) is therefore in the captured stderr of the tool, or ahead of its output withstderr=subprocess.STDOUT, and in the server log only when stderr is inherited. Under"block"with captured stderr the refusal is logged nowhere; theSubprocessBlockedexception is all there is. - gunicorn 26.2.0 has no eventlet worker.
hypercorn and Quart¶
Measured with hypercorn 0.18.0, trio 0.34.0, uvloop 0.23.0; Quart 0.23.1 (0.22.0 on Python 3.11 and 3.12). hypercorn's master never imports the application; each worker is a new Python process that does.
| Arrangement | Result | Pinned by |
|---|---|---|
The asyncio worker (the default), --workers, workers started after --max-requests, SIGHUP and --reload, serve() in a script, a WSGI application |
Enforced. | hypercorn |
-k trio |
Enforced. Importing trio launches ldconfig: see what holds under every server. |
hypercorn |
-k uvloop |
Refused at start-up: the worker imports uvloop before the application. When the master holds the policy the worker's import is refused and hypercorn reports "uvloop is not installed". There is no fallback: this worker class asked for uvloop. | hypercorn |
A configuration file that locks the policy in the master (-c file:conf.py) |
Enforced: the workers inherit the locked policy at start-up, replaced ones too. | hypercorn |
Quart under hypercorn, quart run, quart run --reload, app.run() |
Enforced. | quart |
granian¶
Measured with granian 2.8.4, rloop 0.5.0, uvloop 0.23.0. granian's main process never imports the application; each worker does, after granian has created the worker's event loop. --loop auto (the default) takes rloop if it can be imported, then uvloop, then asyncio's own. And granian imports every loop package it finds as soon as granian itself is imported, whichever --loop was asked for.
| Arrangement | Result | Pinned by |
|---|---|---|
granian with neither rloop nor uvloop installed: ASGI, RSGI, WSGI, --workers, SIGHUP, a respawned worker |
Enforced. | granian |
rloop installed (granian[rloop]) |
Enforced, except an unconnected datagram: transport.sendto(data, addr) reaches a forbidden address and raises no event. |
granian |
uvloop installed (granian[uvloop]), any --loop, --loop asyncio and --loop rloop included |
Refused at start-up: uvloop is loaded although it is not used, and activate() goes by that. |
granian |
uvloop installed, and a launcher that calls activate() before it imports granian |
Enforced: uvloop is not loaded, auto goes on to rloop or asyncio, and the workers inherit the policy. Asking for --loop uvloop by name does not start. |
granian |
sanic¶
Measured with sanic 25.12.1 and uvloop 0.23.0, which sanic installs as a dependency. import sanic itself imports uvloop.
| Arrangement | Result | Pinned by |
|---|---|---|
The sanic command, as installed |
Refused at start-up: the command has imported sanic, and with it uvloop, before it imports the application. SANIC_USE_UVLOOP=false does not help: uvloop is loaded all the same. |
sanic |
A script that calls activate() before it imports sanic |
Enforced: sanic logs that uvloop is not installed and serves on the standard loop. Workers inherit the policy, also workers started by app.m.restart() and by --auto-reload. |
sanic |
| A script that imports sanic first | Refused at start-up. | sanic |
| sanic with uvloop uninstalled | Enforced, with the sanic command as it is. |
sanic |
aiohttp¶
Measured with aiohttp 3.14.4, aiodns 4.0.4, pycares 5.1.0, gunicorn 26.2.0.
| Arrangement | Result | Pinned by |
|---|---|---|
web.run_app(app) in a script, python -m aiohttp.web, gunicorn with aiohttp.GunicornWebWorker |
Enforced, aiohttp.ClientSession included. |
aiohttp |
gunicorn with aiohttp.GunicornUVLoopWebWorker |
Refused at start-up. | aiohttp |
aiohttp.ClientSession with aiodns installed (aiohttp[speedups]), the policy active before aiohttp is imported |
Enforced: tethered keeps pycares from loading and aiohttp uses its threaded resolver. | clients |
The same with aiohttp imported before activate() |
Allowed host refused. python -m aiohttp.web and gunicorn's aiohttp worker class import aiohttp before the application, so this is what they get. Set the resolver (aiohttp.TCPConnector(resolver=aiohttp.ThreadedResolver())), uninstall aiodns, or allow the address. |
aiohttp, clients |
daphne and Twisted¶
Measured with daphne 4.2.3 and Twisted 26.4.0. daphne is one process and runs Twisted's reactor on a standard asyncio event loop.
| Arrangement | Result | Pinned by |
|---|---|---|
| An ASGI application that uses asyncio | Enforced. | daphne |
Twisted's own client API in a handler: connections, lookups, datagrams, reactor.spawnProcess |
Enforced: nothing forbidden gets out. How the refusal comes back differs, see below. | daphne, clients |
| A launcher that locks the policy and then runs daphne | Enforced. | daphne |
A refusal is raised inside the reactor, and Twisted expects OSError and socket.gaierror there, not a RuntimeError:
- A refused connection attempt (
reactor.connectTCP,TCP4ClientEndpoint) reaches the caller as Twisted's ownTimeoutError, when the attempt's timeout is over: 30 seconds unless the caller says otherwise. - A refused lookup is never reported: the
DeferredofHostnameEndpoint.connect,twisted.web.client.Agent.requestandreactor.resolvenever fires. - A refused datagram (
transport.write(data, addr)) and a refusedreactor.spawnProcessraise to the caller. - tethered's own warning is in the log in every case. Twisted's traceback of the refusal is only there with
daphne -v 2. reactor.spawnProcesswith its default, empty environment is anos.posix_spawnwhose environment lacks the policy variable: under a plain policy a Python child is launched with a warning and runs without the policy; under a lock or"block"it is refused.
tornado¶
Measured with tornado 6.5.10 and pycurl 7.48.0.
| Arrangement | Result | Pinned by |
|---|---|---|
HTTPServer in one process, fork_processes (a child that replaces a killed one too), tornado.autoreload |
Enforced. | tornado |
AsyncHTTPClient as tornado ships it (simple_httpclient), TCPClient, Resolver, tornado.process.Subprocess |
Enforced; a refusal is raised to the caller. | tornado, clients |
AsyncHTTPClient configured to use tornado.curl_httpclient |
Not seen: libcurl makes the lookup and the connection. A request to a forbidden destination goes through under a locked, fail-closed policy. | tornado, clients |
uWSGI¶
Measured with uWSGI 2.0.31, which embeds Python in its own binary.
| Arrangement | Result | Pinned by |
|---|---|---|
uwsgi --http :8000 --wsgi-file app.py --master --processes 2 with activate() at the top of app.py |
Enforced in every worker, with no further configuration. Also with --http-socket, --lazy-apps, --threads 4, a thread the application starts under --enable-threads, and workers replaced after --max-requests and after harakiri. |
uwsgi |
A virtualenv: -H venv, or the uwsgi binary inside the virtualenv |
Enforced; tethered.pth of the virtualenv is processed. |
uwsgi |
A reload (SIGHUP, --py-autoreload) |
The reloaded process inherits the policy before it loads the application. A locked policy cannot be changed by reloading: see what holds under every server. | uwsgi |
| A Python child process launched from a handler | Needs configuration: --py-sys-executable /path/to/python. See below. |
uwsgi |
Several applications with --mount |
Each later application runs in an interpreter of its own, and a lock is one per process: see below. | uwsgi |
sys.executable is the uwsgi binary. tethered recognizes a Python child by sys.executable, and under uWSGI that is not an interpreter: subprocess.run([sys.executable, "script.py"]) starts uWSGI, not Python ("unable to load configuration from ..."). tethered notices, logs a warning when the policy is activated (tethered: sys.executable is /usr/local/bin/uwsgi, the program that embeds Python here, not a Python interpreter. ...), and counts every launch as external: warned about, and refused under "block", with the reason in the log line. That includes a launch of the real interpreter (["/usr/local/bin/python", "script.py"]), although that child does inherit the policy; under a lock it is given the policy variable when its explicit environment lacks it, like any program started through subprocess. --py-sys-executable /path/to/python puts it right, with a virtualenv too: the warning is gone and a child started with sys.executable is a Python child that inherits.
Mounted applications. uWSGI loads the first application in the main interpreter and each later one in a new interpreter, unless --single-interpreter is given. With the same locked policy in both, the second joins the lock and its activate() is a no-op. A first application that does not lock, followed by one that does, is outside the lock and has no network at all, loopback included. After a reload the same deployment behaves differently: the main interpreter inherits the lock, and the first application is held to the second's policy. With different locked policies the second application gets TetheredLocked; if it lets the exception out, uWSGI does not start. Unlocked policies in several interpreters share the policy variable, as under mod_wsgi. One policy for all mounted applications, locked by the first, is the arrangement that holds.
uWSGI's own behaviour that matters here: SIGTERM reloads unless --die-on-term is given, and an uncaught exception closes the connection without a response.
waitress¶
Measured with waitress 3.0.2: one process and a pool of threads.
| Arrangement | Result | Pinned by |
|---|---|---|
waitress-serve module:application, and a launcher that calls activate() before it imports waitress |
Enforced on every thread, with requests on all threads at once. | waitress |
mod_wsgi¶
Measured with mod_wsgi 6.1.0 and Apache 2.4 as Debian packages it (2.4.68 when measured). mod_wsgi runs each application group in an interpreter of its own, and a locked policy is one per process, so what counts is the order in which the interpreters of a process come into being. The cookbook has the configurations.
| Arrangement | Result | Pinned by |
|---|---|---|
One application per process: daemon mode and embedded mode, the default application group and %{GLOBAL}, mod_wsgi-express; processes started by a graceful restart, maximum-requests, inactivity-timeout and a touched script |
Enforced. | modwsgi |
| A daemon process group for each application | Enforced; each application has its own policy. | modwsgi |
Several application groups in one process, and a script that only takes the lock imported into the main interpreter at process start (WSGIImportScript ... application-group=%{GLOBAL}) |
Enforced: every group's interpreter is created under the lock and joins it, whenever its first request comes, overlapping first requests included. Also in embedded mode, with applications that never import tethered, and after a graceful restart. | modwsgi |
| Several groups, every script imported at process start, the locking one first | Enforced. | modwsgi |
| A launcher that activates and then executes Apache | Enforced: every interpreter of every process starts under the policy. | modwsgi |
| Several groups, loaded at their first requests, with nothing arranged | One application can end up with no network. When two first requests overlap, one application takes the lock and the other, whose interpreter already existed, is outside it: TetheredLocked, and every connection and launch refused. Nothing forbidden is reached. With threads=1 in the daemon process, and every application locking the same policy, the requests cannot overlap. |
modwsgi |
| Several groups, a script imported before the lock is taken | No network for that application, in every process. | modwsgi |
A virtual environment through python-home |
Enforced, child processes included. | modwsgi |
A virtual environment through python-path |
Only the interpreter that activates is covered: a second group cannot join the lock, and a Python child is the system Python without tethered (warned about; refused under "block"). |
modwsgi |
| Unlocked policies in two groups of one process | Each enforces its own on its connections; a Python child, and an interpreter created later, get the policy activated last. | modwsgi |
%{GLOBAL} next to other groups in one process |
mod_wsgi does not always run a %{GLOBAL} request in the main interpreter: it can run inside the interpreter of another group, with or without tethered. Under a lock held by that other group, such a request gets the other group's policy or no network. Nothing forbidden is reached. |
modwsgi |
On Python 3.12, WSGIPerInterpreterGIL On crashed the daemon process as soon as a second group loaded an application, with and without tethered (measured once, 32 of 32 starts); the suite does not run that combination.
Flask and Django¶
Measured with Flask 3.1.3 (Werkzeug 3.1.9) and Django 6.1.2 (5.2.18 on Python 3.11), under gunicorn 26.2.0, with requests 2.34.2.
| Arrangement | Result | Pinned by |
|---|---|---|
Flask, activate() in the application factory: gunicorn with and without --preload, a flask command, flask run with and without its reloader |
Enforced. The process the reloader starts inherits the policy. | flask |
Flask, an error handler registered for tethered.EgressBlocked |
Reached for a refused lookup and a refused connection. | flask |
Django, activate() in settings.py: gunicorn with and without --preload, a management command, runserver with and without its autoreloader |
Enforced. | django |
Django, a middleware that catches EgressBlocked around get_response() |
Does not work: Django has turned the view's exception into a 500 before get_response() returns. A middleware with process_exception() gives the 503: see the cookbook. |
django |
requests |
Passes EgressBlocked through unwrapped; an adapter with max_retries=3 makes one attempt. |
flask |
What holds under every server¶
An uncaught EgressBlocked does not take a worker down. The request fails the way any uncaught exception does on that server, and the worker keeps serving: a 500 under uvicorn, hypercorn, granian, sanic, aiohttp, daphne, tornado's own server, gunicorn's sync, gthread and gevent workers, waitress, mod_wsgi, Flask and Django; no response under gunicorn's tornado worker (the connection stays open) and under uWSGI (the connection is closed). Those two servers do the same with any uncaught exception when tethered is not installed.
Process launches from a handler, under a locked policy with the default external_subprocess_policy="warn" (suites gunicorn, modwsgi, and each server suite for the first and the last row):
| Launch | Result |
|---|---|
[sys.executable, ...] |
The child inherits the locked policy and enforces it. |
[sys.executable, ...] with an env= that lacks _TETHERED_CHILD_POLICY |
The child is given the variable and inherits the locked policy. |
["python3", ...] |
Warned about as external, although this child does inherit. Refused under "block". |
[sys.executable, "-S", ...] |
Warned about; the child has no policy. Refused under "block". |
["bash", ...] or any other program |
Warned about; the program can reach anything. Refused under "block". |
external_subprocess_policy="block" refuses what libraries launch, too. On Linux, ctypes.util.find_library() runs ldconfig, and platform.platform() runs uname. Under "block":
ctypes.util.find_library()raisesSubprocessBlocked(suiteclients), and with it the import of trio (suitehypercorn). asyncssh and psycopg 3 in its Python implementation make the same call while they are imported. Import such libraries beforeactivate().celery workerdoes not start: its banner callsplatform.platform(), which catches onlyOSError(suitecelery). Callplatform.platform()once beforeactivate(); it caches the answer.
Under the default "warn" these launches are logged as external launches and carried out, under a locked policy as well.
A locked policy survives whatever re-executes the server. The new process image inherits the locked policy from its environment before the application is imported, and a locked policy cannot be changed without its token. So an application that asks for a different locked policy after a re-execution gets TetheredLocked:
| Re-execution | What happens with a changed locked policy | Pinned by |
|---|---|---|
uWSGI reload (SIGHUP, --py-autoreload) |
Uncaught, uWSGI has no application and answers every request with a 500 until it is stopped and started. Caught, the old policy stays in force. | uwsgi |
gunicorn SIGUSR2 with --preload |
The new master does not start; the old one keeps serving under the old policy. | gunicorn |
Django runserver with its autoreloader |
The child cannot import its settings and runserver exits with status 1. |
django |
| mod_wsgi in embedded mode, a touched script | Every request is a 500 until Apache's child processes are replaced. | modwsgi |
Not affected: uWSGI with --lazy-apps, gunicorn without --preload, mod_wsgi in daemon mode (a touched script restarts the process), and unlocked policies. The same policy asked for again is a no-op everywhere. To change a locked policy, stop the server and start it.
Task queues and process pools¶
In every system below, each process that runs task code held the policy, plain and locked, including processes started later, and a Python child of a task inherited it. The suites use Redis 8 on loopback as broker and result store.
| System | Arrangement | Result | Pinned by |
|---|---|---|---|
| Celery | activate() when the application module is imported: prefork, threads, solo and gevent pools; --max-tasks-per-child, a replacement for a killed pool process, --autoscale, a SIGHUP restart; --beat and a beat process of its own |
Enforced. The gevent pool has the hazard described below the table. | celery (celery 5.6.3, kombu 5.6.2, billiard 4.3.1) |
| Celery | activate() in a worker_init handler |
Enforced in every pool. | celery |
| Celery | activate() only in a worker_process_init handler |
Enforced in the prefork pool's children (recycled ones too) and in the solo pool. No policy at all with --pool threads and --pool gevent: Celery never sends that signal there. |
celery |
| Celery | eventlet pool | Allowed host refused; enforced with EVENTLET_NO_GREENDNS=yes, or with a rule for the address. |
celery (eventlet 0.41.2) |
| Celery | external_subprocess_policy="block" |
celery worker does not start, unless platform.platform() was called before activate(). |
celery |
| RQ | Worker (a work horse forked per job), SimpleWorker, SpawnWorker, rq worker-pool, the scheduler |
Enforced. With activate() in the jobs module every job is covered and the worker process itself holds no policy; with rq worker -c settings and activate() in the settings module both are. |
rq (rq 2.12.0) |
| Dramatiq | Worker processes forked, from a fork server, or spawned (--use-spawn); a reload; activate() at import or in an after_process_boot middleware hook |
Enforced in every worker process. The parent never imports the application and holds no policy. | dramatiq (dramatiq 2.2.1) |
| Huey | Thread and process workers; --max-tasks, a replacement for a killed worker, a SIGHUP restart; activate() at import or in an on_startup hook |
Enforced. | huey (huey 3.4.0) |
| arq | arq module.WorkerSettings, coroutine jobs and cron jobs; activate() at import or in on_startup |
Enforced, on asyncio's own loop. | arq (arq 0.28.0) |
multiprocessing.Pool, ProcessPoolExecutor |
fork, spawn and forkserver; recycled workers and a replacement for a dead one; external_subprocess_policy="block" |
Enforced. A worker can neither release nor replace a locked policy. | stdlib_pools |
multiprocessing.Pool, ProcessPoolExecutor |
Workers started before activate() |
No policy until they are replaced; the replacements enforce. | stdlib_pools |
multiprocessing, the forkserver start method |
A fork server started before activate() |
Every later worker has no policy, under a locked policy too. See below. | stdlib_pools |
InterpreterPoolExecutor (Python 3.14+) |
A worker interpreter started under a lock | Enforced: it joins the lock. Under a plain policy it activates the policy it finds. | stdlib_pools |
InterpreterPoolExecutor |
A worker interpreter that existed before the lock | No network at all while the lock is held. One that existed before a plain activate() holds no policy. |
stdlib_pools |
| supervisord | An entry point that activates and then executes supervisord, which starts a Python program | Enforced in the program and in its restarts: the policy travels in the environment. A program whose environment is wiped (env -i) starts without it. |
supervisor (supervisord 4.2.5) |
The fork server. A worker of the forkserver start method is a copy of the fork server process, not of the process that asks for it. A fork server that was started before activate() holds no policy, and neither does any worker it produces from then on, also after activate(locked=True). Asking it for a worker goes over a socket and raises no launch event, so tethered can neither refuse nor complete it. forkserver is the default start method on Linux from Python 3.14. activate() logs one warning when it finds a fork server already running. Call activate() before the first pool or process is started; a fork server that starts under the policy passes it on.
A broker that is addressed by name has to be in the allow list. Otherwise the Celery worker ends at start-up with EgressBlocked for the broker's address (suite celery). kombu retries connection errors; EgressBlocked is not one, so the mistake is not retried for ever.
Celery's gevent pool and a lookup that never returns. The hazard of gevent's thread-pool resolver, described under gunicorn, is there with --pool gevent too: when refused lookups overlap, the task of one of them now and then never finishes. The worker goes on running other tasks. A warm shutdown (SIGTERM) waits for running tasks without a time limit, so with such a task it does not end. Measured once, on Python 3.12 and 3.13 with Celery 5.6.3 and gevent 26.9.0, not asserted by a suite: under eight refused lookups at a time a task was stuck after 152 and after 328 lookups, the worker was still running two minutes after SIGTERM, and it ended at once when it was then sent SIGQUIT, Celery's cold shutdown.
What an operator sees for a refused task. Nothing arrived in any of these, and in no system did the workers die.
| System | Task state and log | Retries | What the producer gets |
|---|---|---|---|
| Celery | FAILURE; Task ... raised unexpected: EgressBlocked('Blocked by tethered: ...') with the traceback |
autoretry_for=(Exception,) retries it; autoretry_for=(OSError,) does not |
result.get() raises tethered.EgressBlocked in a producer that has tethered loaded, with the JSON result serializer (the attributes are read back out of the message) and with result_serializer="pickle". With JSON, a producer that has not imported tethered gets a class of Celery's own that is named EgressBlocked. |
| RQ | failed; the traceback is stored with the job |
Retry(max=2) is used up |
The traceback text. |
| Dramatiq | Failed to process message ... with unhandled exception. |
The Retries middleware retries it | ResultFailure naming EgressBlocked. |
| Huey | Unhandled exception in task ... |
retries=2 gives three attempts |
TaskException. |
| arq | ... failed, EgressBlocked: ... |
Not retried | The EgressBlocked itself, with its attributes. |
| The standard library's process pools | EgressBlocked from .get() / .result(); the pool is not broken. |
Scopes¶
A tethered.scope() around the work of one request or one task, in a process whose policy allows more than the scope does. Measured with gunicorn 26.2.0, uvicorn 0.54.0 on the standard loop, gevent 26.9.0, Celery 5.6.3 and eventlet 0.41.2.
| Where the scope is entered | Result | Pinned by |
|---|---|---|
| A request under gunicorn's sync and gthread workers | Enforced. Inside the scope only what the scope allows is reached. The request that follows on the same thread is not restricted, and a request served at the same time on another thread is not affected. | gunicorn |
| A request under uvicorn, on the standard loop | Enforced for everything the request does on the loop (streams, loop.create_connection, loop.sock_connect, datagrams), for a blocking call made in the loop and for asyncio.to_thread. A request handled at the same time by another task is not affected. |
uvicorn |
A request under gunicorn's gevent workers (gevent, gevent_pywsgi) |
Enforced. A request served at the same time by another greenlet is not affected. | gunicorn_gevent |
| A Celery task, in the prefork, threads, solo, gevent and eventlet pools | Enforced. Tasks that run at the same time in one process (threads, gevent, eventlet) do not see each other's scope. | celery |
A scope in an application that never calls activate() |
Enforced inside the scope; nothing is refused outside it. | gunicorn, uvicorn, gunicorn_gevent |
| A scope that allows what the policy refuses | Refused by the policy. A scope cannot widen it. | gunicorn, uvicorn, gunicorn_gevent, celery |
| A log-only scope | Refuses nothing and logs would block. |
gunicorn |
A Python child launched inside the scope with subprocess or, on the standard loop, asyncio.create_subprocess_exec |
The child holds, from its start, the policy of the process narrowed by the scope. Also under gevent, whose Popen forks and calls os.exec* itself (gunicorn's gevent workers, Celery's gevent pool), and from a Celery task in the eventlet pool. |
gunicorn, uvicorn, gunicorn_gevent, celery |
What a scope does not reach:
- Another thread. A thread started inside the block, and work handed to
loop.run_in_executor, run without the scope and reach what it refuses; the policy of the process still applies to them.asyncio.to_threadcopies the context and stays inside the scope. Under gevent a "thread" is a greenlet, and it starts without the scope in the same way. Suites:gunicorn,uvicorn,gunicorn_gevent. - A name lookup made in a pool thread. asyncio's loop and gevent's default resolver call
getaddrinfoin a thread of a pool, which does not carry the scope. The DNS query for a name the scope refuses therefore leaves the process; a connection to that name is refused by the scope. A blocking lookup in the thread that entered the scope is refused before any query is sent. Suites:uvicorn,gunicorn_gevent.
Scopes were not measured under the other servers and task queues of this page.
Client libraries¶
Each library was asked for the four destinations under the policy allow=["allowed.test:9001"], and, as a control, with no tethered and an audit hook that records what events the library raises. The table is the one the clients suite prints at the end of a run; this copy is from a run on Python 3.13. clients_niquests measures niquests in the same way. Every row is the same under a locked policy as under a plain one.
Of the 70 arrangements of the clients suite, 47 are enforced, 13 are not seen, 4 are partly enforced, and in 6 an allowed host is refused. The two niquests arrangements are one of each of the first and the last kind.
| Result | Forbidden name | Forbidden address | Allowed name | Allowed name, other port | Was the forbidden name sent to the name server? |
|---|---|---|---|---|---|
| Enforced | refused | refused | reached | refused | no |
| Not seen | reached | reached | reached | reached | yes |
| Partly enforced | refused | reached | reached | reached | no |
| Allowed host refused | refused | refused | refused | refused | yes |
The column "A refused name reaches the caller as" says what the code that called the library gets when the forbidden name is refused.
HTTP and WebSocket clients¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
urllib.request |
Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
standard library |
http.client |
Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
standard library |
| requests | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
requests 2.34.2, urllib3 2.8.0 |
| urllib3 | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
urllib3 2.8.0 |
| httplib2 | Enforced | socket.getaddrinfo, then socket.connect with the host name |
EgressBlocked |
httplib2 0.32.0 |
| httpx, sync | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
httpx 0.28.1, httpcore 1.0.9 |
| httpx, async on asyncio | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
httpx 0.28.1, httpcore 1.0.9, anyio 4.15.1 |
| httpx, async on trio | Enforced | trio: socket.getaddrinfo in a worker thread, socket.connect on its sockets |
EgressBlocked |
httpx 0.28.1, httpcore 1.0.9, trio 0.34.0 |
| aiohttp, aiodns installed, the policy active before aiohttp is imported | Enforced | tethered keeps pycares from loading, and aiohttp uses its threaded resolver: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
aiohttp 3.14.4, aiodns 4.0.4, pycares 5.1.0 |
| aiohttp, aiodns installed, aiohttp imported before the policy | Allowed host refused | c-ares resolves in C; the connection is a Python socket's | EgressBlocked |
aiohttp 3.14.4, aiodns 4.0.4, pycares 5.1.0 |
| aiohttp, aiodns not installed | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
aiohttp 3.14.4 |
aiohttp, aiodns installed, TCPConnector(resolver=ThreadedResolver()) |
Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
aiohttp 3.14.4, aiodns 4.0.4 |
| pycurl | Not seen | libcurl resolves and connects in C | nothing is refused | pycurl 7.48.0 |
| curl_cffi | Not seen | libcurl resolves and connects in C | nothing is refused | curl_cffi 0.16.3 |
tornado, SimpleAsyncHTTPClient (its default) |
Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
tornado 6.5.10 |
tornado, CurlAsyncHTTPClient |
Not seen | libcurl resolves and connects in C | nothing is refused | tornado 6.5.10, pycurl 7.48.0 |
| websockets, asyncio client | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
websockets 17.2 |
| websockets, sync client | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
websockets 17.2 |
| niquests, as it comes (the system resolver) | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
niquests 3.21.2, urllib3-future 2.25.902 |
niquests with a resolver of its own (Session(resolver="dou://...")) |
Allowed host refused | urllib3-future makes the DNS queries itself; the connection is a Python socket's | not asserted (the refusal comes at the connection, by address) | niquests 3.21.2, urllib3-future 2.25.902 |
PostgreSQL¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
| psycopg2 (psycopg2-binary) | Not seen | libpq resolves and connects in C | nothing is refused | psycopg2-binary 2.9.13 |
| psycopg 3, binary implementation, sync | Partly enforced | socket.getaddrinfo in Python, then libpq connects to the address in C |
EgressBlocked |
psycopg 3.3.6, psycopg-binary 3.3.6 |
| psycopg 3, binary implementation, async | Partly enforced | socket.getaddrinfo in Python, then libpq connects to the address in C |
EgressBlocked |
psycopg 3.3.6, psycopg-binary 3.3.6 |
| psycopg 3, Python implementation (libpq through ctypes), sync | Partly enforced | socket.getaddrinfo in Python, then libpq connects to the address in C |
EgressBlocked |
psycopg 3.3.6 |
| psycopg 3, Python implementation (libpq through ctypes), async | Partly enforced | socket.getaddrinfo in Python, then libpq connects to the address in C |
EgressBlocked |
psycopg 3.3.6 |
| asyncpg | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
asyncpg 0.32.0 |
| pg8000 | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
pg8000 1.31.5 |
MySQL¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
| PyMySQL | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
PyMySQL 1.2.3 |
| aiomysql | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
aiomysql 0.3.2 |
| mysqlclient (MySQLdb) | Not seen | the MariaDB / MySQL client library resolves and connects in C | nothing is refused | mysqlclient 2.3.0 |
mysql-connector-python, use_pure=True |
Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
mysql-connector-python 26.7.0 |
| mysql-connector-python, C extension (its default when the extension is there) | Not seen | the bundled MySQL client library resolves and connects in C | nothing is refused | mysql-connector-python 26.7.0 |
SQL Server and Oracle¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
| pymssql | Not seen | FreeTDS resolves and connects in C | nothing is refused | pymssql 2.4.4 |
| pyodbc with the FreeTDS ODBC driver | Not seen | the ODBC driver resolves and connects in C | nothing is refused | pyodbc 5.3.0 |
| oracledb, thin mode | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
oracledb 26.0.1 |
| oracledb, thin mode, async | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
oracledb 26.0.1 |
Redis, MongoDB, memcached¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
| redis-py, hiredis installed | Enforced | socket.getaddrinfo and socket.connect; hiredis only parses replies |
EgressBlocked |
redis 8.1.0, hiredis 3.4.2 |
| redis-py, hiredis not installed | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
redis 8.1.0 |
| redis-py asyncio, hiredis installed | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets; hiredis only parses replies |
redis.ConnectionError, with EgressBlocked chained |
redis 8.1.0, hiredis 3.4.2 |
| redis-py asyncio, hiredis not installed | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
redis.ConnectionError, with EgressBlocked chained |
redis 8.1.0 |
pymongo, MongoClient |
Enforced | socket.getaddrinfo and socket.connect |
ServerSelectionTimeoutError; the refusal is in its text only |
pymongo 4.18.3 |
pymongo, AsyncMongoClient |
Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
ServerSelectionTimeoutError; the refusal is in its text only |
pymongo 4.18.3 |
| pymemcache | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
pymemcache 4.0.0 |
Messaging and RPC¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
| grpcio | Not seen | the gRPC core resolves and connects in C | nothing is refused | grpcio 1.84.0 |
grpcio, grpc.aio |
Not seen | the gRPC core resolves and connects in C | nothing is refused | grpcio 1.84.0 |
| pika | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
pika 1.4.4 |
| amqp (py-amqp, the transport of kombu and Celery) | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
amqp 5.4.1 |
| kafka-python | Enforced | socket.getaddrinfo and socket.connect |
KafkaTimeoutError: Unable to bootstrap, which does not mention tethered |
kafka-python 3.0.11 |
| confluent-kafka | Not seen | librdkafka resolves and connects in C | nothing is refused | confluent-kafka 2.16.0 |
| nats-py | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
NoServersError, which does not mention tethered |
nats-py 2.16.0 |
| pyzmq | Not seen | libzmq resolves and connects in C | nothing is refused | pyzmq 27.2.0 |
Cloud SDKs¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
| boto3 / botocore, awscrt installed | Enforced | urllib3: socket.getaddrinfo and socket.connect |
botocore's HTTPClientError, with EgressBlocked chained |
boto3 1.43.111, botocore 1.43.111, urllib3 2.8.0, awscrt 0.37.0 |
awscrt (awscrt.http), the transport of boto3's optional CRT transfer manager |
Not seen | the AWS Common Runtime resolves and connects in C | nothing is refused | awscrt 0.37.0 |
| google-cloud-storage | Enforced | requests: socket.getaddrinfo and socket.connect |
EgressBlocked |
google-cloud-storage 3.17.0, requests 2.34.2 |
Sockets, standard-library protocol clients, SSH¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
socket.create_connection |
Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
standard library |
asyncio.open_connection |
Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
standard library |
| smtplib | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
standard library |
| ftplib | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
standard library |
| paramiko | Enforced | socket.getaddrinfo and socket.connect |
EgressBlocked |
paramiko 5.0.0 |
| asyncssh | Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
asyncssh 2.24.1 |
A resolver of the library's own, then a Python socket¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
dnspython lookup (dns.resolver), then a socket connection to the address |
Allowed host refused | its own DNS client: socket.sendto to the name server, no socket.getaddrinfo |
EgressBlocked |
dnspython 2.9.0 |
| pycares imported before the policy: a lookup, then a socket connection to the address | Allowed host refused | c-ares resolves in C; the connection is a Python socket's | EgressBlocked |
pycares 5.1.0 |
aiodns imported before the policy: a lookup, then asyncio.open_connection to the address |
Allowed host refused | c-ares resolves in C; the connection is a Python socket's | EgressBlocked |
aiodns 4.0.4, pycares 5.1.0 |
Other runtimes¶
| Library and arrangement | Result | Why | A refused name reaches the caller as | Measured with |
|---|---|---|---|---|
trio.open_tcp_stream |
Enforced | trio: socket.getaddrinfo in a worker thread, socket.connect on its sockets |
EgressBlocked |
trio 0.34.0 |
anyio.connect_tcp on asyncio |
Enforced | asyncio: socket.getaddrinfo in the loop's executor, socket.connect on its sockets |
EgressBlocked |
anyio 4.15.1 |
anyio.connect_tcp on trio |
Enforced | trio: socket.getaddrinfo in a worker thread, socket.connect on its sockets |
EgressBlocked |
anyio 4.15.1, trio 0.34.0 |
gevent, monkey.patch_all(), urllib.request (its default resolver, a thread pool) |
Enforced | socket.getaddrinfo in gevent's thread pool; the green socket calls socket.connect |
EgressBlocked |
gevent 26.9.0 |
gevent, monkey.patch_all(), GEVENT_RESOLVER=ares |
Allowed host refused | c-ares resolves in C; the green socket calls socket.connect |
EgressBlocked |
gevent 26.9.0 |
eventlet, monkey_patch(), urllib.request (greendns, its default) |
Allowed host refused | greendns (dnspython): socket.sendto to the name server, no socket.getaddrinfo for the name; the green socket calls socket.connect |
EgressBlocked |
eventlet 0.41.2, dnspython 2.9.0 |
eventlet, monkey_patch(), EVENTLET_NO_GREENDNS=yes |
Enforced | socket.getaddrinfo itself; the green socket calls socket.connect |
EgressBlocked |
eventlet 0.41.2 |
Twisted, reactor.connectTCP (TCP4ClientEndpoint) |
Enforced | socket.getaddrinfo in the reactor's thread pool, socket.connect_ex on its sockets |
Twisted's TimeoutError, after the connection timeout |
Twisted 26.4.0 |
Twisted, twisted.web.client.Agent (HostnameEndpoint) |
Enforced | socket.getaddrinfo in the reactor's thread pool, socket.connect_ex on its sockets |
nothing: the request never completes | Twisted 26.4.0 |
Notes on the table¶
Refusals at the connection can arrive inside an ExceptionGroup. The last column is about a refused name, which is refused at the lookup. httpx.AsyncClient, anyio.connect_tcp and trio.open_tcp_stream deliver a refusal at the connection (a port rule, or a raw address) inside an ExceptionGroup, which except tethered.EgressBlocked does not catch. Use except* (the cookbook).
What makes a library that resolves names itself work. Each of these was measured:
- aiohttp: call
activate()before aiohttp is imported, or passaiohttp.TCPConnector(resolver=aiohttp.ThreadedResolver()), or uninstall aiodns. - eventlet:
EVENTLET_NO_GREENDNS=yes. - gevent: its default resolver, not
ares. - niquests: no
resolver=argument. - Any of them: one lookup of the name through the standard resolver by the application before the library is used, which gives tethered the name. The suite calls
_socket.getaddrinfo(host, port)or_socket.gethostbyname(host), the C functions behind thesocketmodule's, because gevent and eventlet replacesocket.getaddrinfowith their own resolver. Or a rule for the address itself; the library's own resolver then still sends every name it is asked for to the name server.
The first time a connection is refused to an address that tethered has no name for, under a policy with hostname rules, tethered logs one line that says where the name may have gone (tethered: no hostname is known for the refused address ...). It is said once per process.
pycares is kept from loading. With a policy active that is not log_only, import pycares raises ImportError, and so does import aiodns. aiohttp then uses ThreadedResolver. A pycares that was imported before activate() is left alone, and activate() is not refused: what it resolves is refused at the connection, so nothing gets out unnoticed, with one exception. c-ares sends its DNS queries in C: a query to a name server at a forbidden address is sent (pinned by clients), where the same query by dnspython is refused at socket.sendto. Under a log_only policy pycares loads, and aiohttp with aiodns then reports the allowed host as blocked, by its address.
Loaded is not used. Whether a C library's networking is in play cannot be read from what is imported: _awscrt is loaded by every botocore import when awscrt is installed, although boto3 keeps using urllib3; grpc._cython.cygrpc is loaded by google-cloud-storage, which makes its requests with requests; psycopg2._psycopg is loaded by eventlet.monkey_patch().
Not in the table: psycopg 3's c implementation (the same code as binary, built against the system's libpq); boto3's CRT transfer manager (it could not be pointed at the test destinations; its transport is the awscrt row); gevent's dnspython resolver (with gevent 26.9.0 and dnspython 2.9.0 it resolves nothing, with or without tethered). On Python 3.15 confluent-kafka and the C extension of mysql-connector-python had no wheel when this was measured, so those two rows are from Python 3.11 to 3.14.
Installing, upgrading and removing tethered¶
What tethered puts on disk is what its wheel holds: the tethered/ package with the compiled extension, tethered.pth beside it, and the .dist-info directory. At run time it writes no file. While a policy is active it keeps _TETHERED_CHILD_POLICY in the environment of the process, where every child program finds the allow list, and deactivate() removes it. tethered.pth is read by every interpreter that starts in the environment, and it imports tethered only when that variable is set.
The rows below are measured once, with pip 26.2.1 and uv 0.13.0 on Python 3.13, on Linux (the image of a suite; python:3.13-slim for uv) and on Windows 11. No suite asserts them.
| Arrangement | What happens |
|---|---|
pip uninstall tethered from a shell, or from a process that holds no policy |
Nothing is left in site-packages. |
| The same from a Python child of a process that holds an unlocked policy, on Linux | Works, and nothing is left. The pip process itself runs under the policy it inherited. |
The same on Windows, and pip install --force-reinstall there |
pip removes or replaces the package but cannot delete the extension module, which is loaded: site-packages\~ethered\_guardian.cp313-win_amd64.pyd stays behind, with a warning from pip. Delete the directory once the process has ended. |
pip uninstall tethered or pip install --force-reinstall (an upgrade takes the same route) from a Python child of a process that holds a locked policy |
Refused. pip gets SubprocessBlocked when it moves tethered.pth and exits with status 2. By then it has renamed tethered-<version>.dist-info to ~ethered-<version>.dist-info, and it does not rename it back: tethered still works and enforces, and pip no longer lists it (WARNING: Ignoring invalid distribution ~ethered). To repair, rename the directory back from outside the locked processes; pip then lists and removes the package as before. |
shutil.rmtree() of the environment a locked process runs from, on Linux |
Refused at tethered.pth, with whatever it had removed before that gone. |
uv pip uninstall tethered launched from a locked process, on Windows |
uv is not Python, so the guard does not see what it does; the launch is logged as external (and refused under external_subprocess_policy="block"). It removed tethered.pth and most of the package, then failed at the loaded extension module. |
tethered installed by uv (uv add, uv pip install), on Linux and on Windows |
tethered.pth in site-packages is a hard link to a file of the same name in uv's cache; with --link-mode=copy it is a file of its own, as with pip. Under a locked policy a write to the cache's file is refused like a write to the hook itself. |
Another distribution that ships a top-level tethered.pth, installed after tethered with pip or with uv |
It replaces tethered's file, and neither installer says so. tethered does not take that file for its bootstrap: activate() logs a warning, and a Python child is an external launch (refused under external_subprocess_policy="block"). Uninstalling that distribution with pip removes tethered.pth altogether; reinstall tethered. |
Install, upgrade and remove tethered from outside the processes that hold a locked policy: a deployment step or a shell, not a child of the application. The locked-mode guard on tethered.pth is described in the subprocess page; it refuses the file by its name and directory, however the installer names that directory, and the same file where uv keeps it in its cache. Windows does not delete a loaded extension module for any installer, so the ~ethered directory is to be expected whenever tethered is upgraded while a process has it imported.
What was not tested¶
- Installers. Anything but pip 26.2.1 and uv 0.13.0 on Python 3.13, and of uv only the arrangements above. conda, poetry and pipx were not run.
- Platforms. Windows and macOS servers, musl (Alpine), ARM, and free-threaded builds of Python. Everything here ran on Linux x86-64 with glibc. The unit tests in
tests/run on Windows and Linux; they do not start servers. - The network itself. The destinations are on the loopback interface of a container without a network. TLS, proxies, connection reuse, HTTP/2 and HTTP/3, and real DNS were not exercised. WebSockets were tested as a client (
websockets), not as a server protocol. - Client libraries. oracledb in thick mode, pyodbc with Microsoft's driver, the
cimplementation of psycopg 3, pyarrow, polars and duckdb, DNS over HTTPS, TLS or QUIC in niquests, and what any client library does on uvloop. - Asynchronous servers. Litestar, Django Channels itself and Django's ASGI entry point, gunicorn's own ASGI worker beyond its choice of loop, granian
--reloadand its embedded server, sanic's inspector and ASGI mode, hypercorn and sanic with a worker that is killed, arq with--watch. - WSGI servers. uWSGI's emperor, mules, spooler, cheaper mode and
--touch-reload, its gevent and asyncio plugins, and uWSGI behind a web server that speaks the uwsgi protocol; waitress on anything but TCP; a mod_wsgi packaged by a distribution, Apache's worker MPM, and mod_wsgi on Windows; the Flask reloader with a changed locked policy. - Task queues. Brokers other than Redis,
celery multiand--detach, Celery's pool-restart remote control,rq cron,dramatiq-gevent, Huey's greenlet workers,supervisorctl,multiprocessing.Processon its own and managers. - uvicorn releases other than 0.54.0 and 0.46.0. The statements about 0.47.0, 0.50.0 and 0.36.0 come from single runs.