The timeout was being detected. The delay came from leaving the context that owned the thread pool. If your logs suggest that future.result(timeout=1) ignored its limit, check where the exception is caught before increasing the limit or replacing the library.
The outside handler has to wait for context cleanup
This complete reproduction uses only Python’s standard library. The start event makes sure the worker has begun before the caller starts waiting for its result. The worker deliberately sleeps; it does not contact a server or modify a file.
from concurrent.futures import (
ThreadPoolExecutor as Pool,
)
from threading import Event
from time import monotonic as now
from time import sleep
started = Event()
t0 = now()
def elapsed():
return round(now() - t0, 2)
def worker():
started.set()
sleep(4)
return 42
try:
with Pool(1) as pool:
future = pool.submit(worker)
started.wait()
future.result(timeout=1)
except TimeoutError:
print(elapsed())
print(future.done())
In our two runs, the outer handler began at 4.031 and 4.000 seconds. It printed True for future.done() both times. That does not mean the result became available within the allowed wait. By the time control reached the handler, cleanup had already waited for the task to finish.
The CPython 3.12.14 implementation of Executor.__exit__ calls shutdown(wait=True). Exception unwinding takes that same exit path. The original TimeoutError then reaches the outside handler; a worker finishing during cleanup does not turn that earlier timeout into a successful result.

An inside handler reports the timeout sooner
Keep the same imports, worker and timer, but move the try and except inside the pool’s context. Add a print after the context to make the remaining wait visible.
with Pool(1) as pool:
future = pool.submit(worker)
started.wait()
try:
future.result(timeout=1)
except TimeoutError:
print("caught", elapsed())
print("running", future.running())
print("cancel", future.cancel())
print("left with", elapsed())
Our inside handlers ran at 1.000 and 1.015 seconds. The future was still running, and cancel() returned False. The final print after with arrived at 4.000 and 4.015 seconds. Moving the handler improved notification timing; it supplied no instruction for the worker to stop.
The Python future contract makes that distinction explicit. A result timeout limits the caller’s wait. Cancellation succeeds only while a task can still be cancelled; an already running call is outside that condition. shutdown(cancel_futures=True) also leaves running calls alone.
Nonwaiting shutdown can leave the process alive
We tested a third variant without a with block. After catching the timeout, it called pool.shutdown(wait=False, cancel_futures=True). The caller returned from shutdown and reached the end of its main code at about 1.015 seconds in both runs.
An independent parent process measured when that Python child actually exited. It took 4.156 and 4.140 seconds. A quick return from shutdown had not removed the running worker. The child’s lifetime included interpreter startup and exit overhead, so it is deliberately reported separately from its internal timestamps.
The thread executor’s interpreter-exit code joins its registered worker threads. That explains why a script can print “finished” promptly yet remain present in the process list. In a long-lived application, returning to a caller can similarly leave work consuming a worker slot. It has not been stopped merely because its result is no longer wanted.
An executor is useful for concurrent operations, such as the DNS verification calls in our server-log analysis example. When adding time limits to such a loop, identify the blocking operation inside each task as well as the caller waiting on the future. This article tests executor lifetime; it does not test DNS cancellation or analyze search-performance data.
A stop signal works when the worker observes it
Our fourth control replaced the uninterruptible sleep with an Event.wait(4). On timeout, the caller set the event. That gave the worker an operation it could actually respond to. Here is a complete version, including normal executor cleanup.
from concurrent.futures import (
ThreadPoolExecutor as Pool,
)
from threading import Event
from time import monotonic as now
started = Event()
stop = Event()
t0 = now()
def worker():
started.set()
if stop.wait(4):
return "stopped"
return "completed"
with Pool(1) as pool:
future = pool.submit(worker)
started.wait()
try:
future.result(timeout=1)
except TimeoutError:
stop.set()
print(round(now() - t0, 2))
print(future.result())
The context exited at 1.016 and 1.031 seconds; both child processes exited after 1.141 seconds including startup and shutdown. The Event documentation describes why this control responds: setting the flag wakes a thread waiting on that event. The executor still waited, but the worker now had a reason to return.
An event does not interrupt arbitrary code. A real worker needs to check it between units of work and release resources on return. If it is blocked in a socket read, database call or native routine, it can only observe the event once control comes back. Bound those operations using their own supported mechanisms. For example, Requests documents separate connection and read waits; neither is a blanket wall-clock guarantee for a whole download. We did not execute an HTTP test for this article.
For an operation that must be terminated independently, a separately owned process with an explicit supervisor can provide a different boundary. That requires a termination and cleanup design; switching to ProcessPoolExecutor or adding a future timeout alone does not supply one. Our JavaScript body-read experiment covers an actual HTTP cancellation mechanism in another runtime; its fix should not be transplanted into a Python thread pool.
Measure the caller and the worker separately
We ran each of the four variants twice in a fresh child process on Windows 11 with CPython 3.12.14, on 2 October 2026. Every child used one worker and exited successfully. Internal events used time.monotonic(); a separate parent timed process completion. These eight controlled runs establish the sequence, not a throughput benchmark or a guarantee of exact millisecond timing on other machines.
For your own diagnosis, log when the future wait raises, when the handler begins, when the worker releases its resources and when the executor or process exits. Those are distinct events. Our SQLite cursor test uses the same practical distinction between an outer status and a still-held resource, with a different API and failure mode.
An outer handler appearing late points you towards context cleanup. An inner handler appearing promptly while the worker continues points you towards cancellation support in the task. Record both before deciding that the one-second wait itself is broken.




