The script stops with “image not found”. You check, and the file is right where it should be. What actually happened is that a child process ran out of time, and a few lines of error handling turned “slow” into “missing”.
except erases the difference between “it isn’t there” and “it didn’t answer in time”, and the error message then names the wrong cause. In Python, a subprocess.run() call with a timeout raises subprocess.TimeoutExpired. Catch it separately, before any broader except, and carry three outcomes to the message: it worked, it timed out, it failed. Don’t let a fallback repeat the same slow call after a timeout, because it doubles the wait and fails the same way. Set the timeout from measurements taken on a busy machine, make it configurable, and put the cause in the message, since the message decides where someone starts looking.Table of Contents
“Number not in service” versus “nobody picked up”
When a phone call fails, the recording you hear tells you what to do next. “This number is not in service” sends you to check the number. “Nobody is answering” sends you to try later. A phone that played the first message every time the line rang out would have you re-typing a perfectly good number all afternoon.
Error handling does the same thing to whoever reads its messages. A timeout and a missing file have different causes and different fixes. Report them with the same words, and the person debugging works on the wrong one.
How a timeout turns into “not found”
Take a common setup: a Python script calls an external command-line tool to export an image, then uses the exported file. In one real case the tool rasterized an SVG in a headless browser. On a quiet machine the export took 13.6 seconds. On a day when several other jobs and an image render were running on the same machine, it took more than 120 seconds, and the script’s timeout was a fixed 120.
Three small decisions then stacked up:
- The timeout was caught as a generic failure. The export call sat inside
try: … except Exception: return False, andTimeoutExpiredis anException, so “too slow” came back asFalse, the same value as “no such image”. - A fallback ran the same slow export again. The fallback existed for a real reason (an older version of the export tool lacked two options, so the script could retry without them and convert the image itself). But it ran on any failure, including the timeout, so it rendered the same SVG again, timed out again, and doubled the wait.
- The final message named the wrong cause. With both attempts returning
False, the script stopped with “image not found”.
The natural next step was to hunt for a missing image. A search found it at once, and a manual export succeeded. Run again on a quiet machine, the same export finished in 13.6 seconds and the script completed normally. Nothing had been missing at any point.
A catch-all except doesn’t hide the error. It hides which error it was.
The pattern, before and after
Reduced to its shape, the original looked like this:
def export(dest):
try:
r = subprocess.run(cmd + [str(dest)], capture_output=True, timeout=120)
except Exception:
return False # a timeout lands here too
return r.returncode == 0 and dest.exists()
if not export(out) and not export(raw): # the fallback repeats the slow call
raise SystemExit("ERROR: image not found")
Code language: Python (python)
The fix keeps three outcomes apart and lets the caller decide what each one means:
import os
import subprocess
EXPORT_TIMEOUT = int(os.environ.get("EXPORT_TIMEOUT", "300"))
def export(dest) -> str:
"""'ok' | 'timeout' | 'fail'"""
try:
r = subprocess.run(cmd + [str(dest)], capture_output=True, timeout=EXPORT_TIMEOUT)
except subprocess.TimeoutExpired: # must come before the broad except
return "timeout"
except Exception:
return "fail"
return "ok" if r.returncode == 0 and dest.exists() else "fail"
result = export(out)
if result == "fail": # the fallback is for a failure, not for slowness
result = export(raw)
if result == "timeout":
raise SystemExit(f"ERROR: export timed out after {EXPORT_TIMEOUT} s; "
"raise EXPORT_TIMEOUT or retry when the machine is quieter")
if result == "fail":
raise SystemExit("ERROR: no match, or the export failed")
Code language: Python (python)
The order of the two except clauses matters. TimeoutExpired is a subclass of Exception, so if the broad clause came first it would catch the timeout and the specific one would never run.
On timeout, subprocess.run() has already cleaned up: the Python documentation says the child process “will be killed and waited for” before the exception is raised. You don’t need to kill it yourself, which is one less reason to reach for a catch-all.
The difference, measured
To check the fix in isolation, the export was replaced with a child process that sleeps for 5 seconds, and both versions got a 2-second timeout. Two runs each:
before: ERROR: image not found (4.0 s, 2 attempts)
before: ERROR: image not found (5.0 s, 2 attempts)
after: ERROR: export timed out after 2 s (raise EXPORT_TIMEOUT or retry when the machine is quieter) (2.0 s, 1 attempt)
after: ERROR: export timed out after 2 s (raise EXPORT_TIMEOUT or retry when the machine is quieter) (2.0 s, 1 attempt)Code language: CSS (css)
Saving two seconds matters less than what the message now says. The old one sends you looking for a missing file. The new one says the call was slow and names the setting to change.
The error message is the first debugging step, and whoever reads it takes the step it points to.
Four rules for subprocess error handling
- One outcome per cause. “Not there”, “too slow” and “broke” need different responses, so they need different return values all the way up to the message. A boolean can only carry two of them.
- Fall back for failures, not for slowness. Retrying the same work after a timeout only repeats the wait. Retry a timeout only if the slowness is likely to pass, such as a network hiccup, and then with a pause in between. A fallback written for an old tool version is never an answer to a busy machine.
- Size the timeout on a busy machine. An export that takes 13.6 seconds idle and over 120 under load will fail against a limit chosen from the idle number, on exactly the day the machine is busiest. Raising the default to 300 seconds and reading it from an environment variable means a slow day costs a setting change instead of a code change.
- Put the cause and the setting in the message. “Timed out after 120 s (EXPORT_TIMEOUT)” tells the reader what happened and what to change. “Not found” tells them something false.
The same blindness shows up wherever a check reports less than it knows. Publishing workflows have a close cousin in render bugs that pass every check, where every check stays green because none of them looks at the output.
The short version
- A catch-all
exceptturns a timeout into whatever the code reports for a failure, here “not found”. - In Python, catch
subprocess.TimeoutExpiredbefore any broaderexcept, and return distinct outcomes: ok, timeout, fail. - Don’t let a fallback repeat a timed-out call. It doubles the wait and fails the same way.
- Measure the timeout under load and make it configurable. In the case above, the same export went from 13.6 seconds idle to over 120 on a busy machine.
- Name the cause in the message, and the setting that changes it. The message decides where the debugging starts.
Frequently Asked Questions
Why does my script say a file is not found when the file exists?
Often because a different error was reported with the same words. If the code that fetches or exports the file catches every exception and returns a generic failure, then a timeout, a permission error and a genuinely missing file all reach the message as “not found”. Check what the call actually raised before looking for the file.
Does subprocess.run kill the process when the timeout expires?
Yes. According to the Python documentation, when the timeout expires the child process is killed and waited for, and then TimeoutExpired is raised. With the lower-level Popen.communicate(), you kill the child yourself.
Do check_output() and call() need the same handling?
Yes. subprocess.call(), check_call() and check_output() all accept a timeout argument and raise the same subprocess.TimeoutExpired when it runs out. check_call() and check_output() also raise CalledProcessError on a non-zero exit, so catch the two separately: one means the tool was too slow, the other means it ran and failed.