Timeout

A Timeout limits how long the code in a with-statement may run. Entering the context makes it the current timeout of the calling thread - as a WarningCollector is made the current collector - so code anywhere below in the call hierarchy asks for the time left, without the timeout being passed along as a parameter.

from pyTooling.Timeout import Timeout

with Timeout(20.0):
  for package in packages:
    Timeout.RaiseIfExpired("Scanning the dependencies took longer than 20 seconds.")

    response = session.get(package.URL, timeout=Timeout.Remaining())

Important

The timeout is cooperative. Python can’t stop a running thread from outside, so nothing interrupts the code in the with block when the deadline passes. A timeout takes effect where the code checks it - a loop aborting itself when IsExpired() turns True - and where the time left is handed to a blocking call as its own timeout.

Asking for the Time Left

Three classmethods answer for the current timeout of the calling thread:

Remaining()

The seconds left as a float, 0.0 once the timeout expired, and None when no timeout is current.

IsExpired()

True once the timeout expired; False before, and when no timeout is current.

Current()

The current timeout itself - like threading.current_thread() returns the thread - or None. Its Seconds and Parent tell how it was set up.

A timeout is entered once at a time: entering it again inside its own with block raises TimeoutUsageError. After its block, it can be entered again.

The time left is what a blocking call gets as its own timeout - a request (requests: timeout=, aiohttp: ClientTimeout(total=...)), threading.Thread.join(), concurrent.futures.Future.result(), asyncio.timeout() or asyncio.wait_for(). A loop calls RaiseIfExpired() once per iteration and so aborts itself with a TimeoutExpiredError, which is a TimeoutError too. The error carries the configured timeout (Seconds) and by how many seconds the deadline was overrun (Overrun) - how long the last iteration ran past it. A loop that needs to clean up first asks IsExpired() and raises itself.

The time runs from entering the context; the deadline is a time.perf_counter_ns() value, so a change of the system’s clock doesn’t shorten or extend it.

Nesting

A timeout entered while another one is current can only shorten the time left, never extend it: its deadline is the earlier of its own and the outer one’s. Leaving the inner context makes the outer timeout current again.

with Timeout(20.0):        # the whole dependency scan
  with Timeout(5.0):       # one package: at most 5 seconds, and never beyond the 20
    ...
  with Timeout(60.0):      # still ends with the outer 20 seconds
    ...

Threads and Tasks

The current timeout is stored per thread.

  • asyncio tasks run in the thread of their event loop, so they see the timeout of that thread.

  • A new thread starts without a current timeout. Hand the time left over to it: wait for it no longer than the time left, or let it enter a timeout of its own with the seconds read before starting it.

from threading import Thread

def worker(remaining: float) -> None:
  with Timeout(remaining):
    ...

with Timeout(20.0):
  thread = Thread(target=worker, args=(Timeout.Remaining(), ))
  thread.start()
  thread.join(timeout=Timeout.Remaining())