I was wrong about rethrowing. The throw = false kwarg returns only a Downloads.RequestError. All other exceptions are thrown anyway.
The root cause seems to be more troublesome: in some circumstances, a try/catch block does not catch a StackOverflowError. The error is printed directly to stderr but the process continues running in a corrupted state.
EDIT: Indeed, asynchronous SOs cannot be caught reliably, see the GH issue What to do about asynchronous exceptions.
Excuse me for pasting a Claude generated blob; I wasn’t able to track this down by myself:
StackOverflowError skips try/catch/finally in compiled frames
I hit this chasing why a catch around some network code never fired on a stack overflow,
and the behaviour turned out to be more general than I expected: a StackOverflowError
does not run catch or finally in a compiled frame. It unwinds past them to the
nearest interpreted handler — top level, typically — or, if it is inside a Task, it just
fails the task. No handler in between gets a chance to run.
MWE
f(n) = f(n + 1) + 1 # exhaust the stack
const ran = Ref(:neither)
g() = try f(0) catch; ran[] = :catch finally ran[] = :finally end
# 1. try/catch/finally in a compiled frame
esc = try g() catch e; typeof(e) end
println("1. compiled handler ran : ", ran[])
println(" escaped to top level : ", esc)
# 2. the same compiled handler, in a task
ran[] = :neither
t = @async g()
wait(t; throw = false)
println("2. compiled handler ran : ", ran[])
println(" istaskfailed : ", istaskfailed(t), ", t.result::", typeof(t.result))
# 3. the same try/catch interpreted, in a task
body = :(try f(0) catch e; println("3. interpreted catch ran: ", typeof(e)) end)
t = @async Core.eval(Main, body)
wait(t; throw = false)
println(" istaskfailed : ", istaskfailed(t))
Output (stdout):
1. compiled handler ran : neither
escaped to top level : StackOverflowError
2. compiled handler ran : neither
istaskfailed : true, t.result::StackOverflowError
3. interpreted catch ran: StackOverflowError
istaskfailed : false
and on stderr, three raw writes from the runtime:
Warning: detected a stack overflow; program state may be corrupted, so further execution might be unreliable.
Case 1 is the surprise: g’s catch never runs, and neither does its finally, yet the
error is perfectly catchable one frame further out, at top level. Case 2 is the same thing
inside a task — the task fails, and the only in-process way to see it is at the join
(istaskfailed(t) / t.result). Case 3 is the control: exactly the same try/catch,
evaluated by the interpreter, catches it even inside a task.
The compiled-vs-interpreted reading is confirmed by --compile=min, where every handler in
the same script runs normally:
1. compiled handler ran : finally
escaped to top level : catch
2. compiled handler ran : finally
istaskfailed : false, t.result::Symbol
Confirmed for Julia 1.13.0 on both macOS 27 and CentOS Stream 10.