March 13, 2026 - Tagged as: en, plt.
Robert Harper’s “Exceptions Are Shared Secrets” is an intriguing blog post, but it may come as a bit abstract unless you’re already familiar with the idea of accidental exception (or more generally, effect) handling, as the post has no code.
In this post I want to give an example of the problems mentioned in the original post, and say a few words on how we might go about working around or fixing these issues.
The original post makes three assumptions about what an exception is and how it should be used:
My understanding of “dynamic classification” is that the cooperation between a raiser and handler doesn’t happen via static types (or any other static mechanism), but by agreeing upon some dynamic features of the values being passed, in runtime (e.g. identity of the object being raised).
I found it to be very difficult to come up with a real-world example of accidental exception handling causing a real bug, and I’m not interested in hypothetical issues that much. So for a long time I thought the issue is not that “real”. It was only by coincidence that I came across an example in a discussion on stack switching in WebAssembly. Here’s my Python rewrite of the original example demonstrating the issue: (full code in a few languages at the end of the post)
We’re implementing sequences that call a callback with the elements in the sequence:
## The base class for sequences.
class Sequence:
def for_each(self, consumer: Callable) -> None:
raise NotImplementedError
## Counts from a given integer up. Does not stop.
class CountFrom(Sequence):
def __init__(self, start: int):
self.start = start
def for_each(self, consumer: Callable) -> None:
i = self.start
while True:
consumer(i)
i += 1
## An empty sequence: does not call the callback.
class Empty(Sequence):
def for_each(self, consumer: Callable) -> None:
passWe want to implement a sequence that takes two sequences and an amount as arguments. It runs the first sequence the given number of times, and then runs the second sequence in full.
A problem here is that sequences don’t support stopping after a while, they always run until completion (or forever, as in CountFrom). So how do we stop the first sequence after the given number of times?
We throw an exception in the first sequence’s callback and catch it in the call site that runs the first sequence. Here’s the full AppendAfter that implements this idea:
## The exception used to signal that the first sequence should be stopped, in
## `AppendAfter`.
class AppendAfterException(Exception):
pass
## Runs the first sequence `amount` times, then runs the second sequence.
class AppendAfter(Sequence):
def __init__(self, first: Sequence, amount: int, second: Sequence):
self.first = first
self.amount = amount
self.second = second
def for_each(self, consumer: Callable) -> None:
count = self.amount
# The callback for the first sequence. Throws an exception after being
# called `amount` times to stop iterating the first sequence.
def limited_consumer(element):
nonlocal count
# Note: weird `count` update below is intentional.
current = count
count -= 1
if current == 0:
raise AppendAfterException()
consumer(element)
# Run the first sequence until the callback throws, signalling to stop
# the first sequence.
try:
self.first.for_each(limited_consumer)
except AppendAfterException:
pass
self.second.for_each(consumer)Here’s an example of how this works:
AppendAfter(CountFrom(0), 5, Empty()).for_each(print)This prints: 0, 1, 2, 3, 4. (each on a new line)
But the code also has a bug. Here’s another use of it that doesn’t work as expected:
AppendAfter(
AppendAfter(CountFrom(0), 10, CountFrom(20)),
5,
Empty()
).for_each(print)This counts to 4, then jumps to 20, and then loops infinitely.
Here’s the problem: the outer AppendAfter counts to 5 in the callback it passes to the inner AppendAfter and then throws an exception to stop iteration. The inner AppendAfter passes the same callback to its first sequence, while also counting. When the outer AppendAfter’s callback throws after 5 iterations, the exception is handled by the inner AppendAfter’s exception handler. So the outer AppendAfter never sees this exception, and it keeps running its first sequence.
The outer sequence never throws an exception again, because of the way we update the count local: we update it first and then check for its previous value. This looks strange in Python, but in a language with pre/post increments/decrements it looks more plausible:
if (count-- == 0) {
throw AppendAfterException();
}
Once this exception is caught by a wrong handler, count never becomes 0 again, so the iteration never stops.
According to the original post, an exception should be a “shared secret” between a raiser and a handler, meaning no other handler (other than the intended one) should be able to intercept and decipher it.
I’m not aware of any language that allows this kind of exceptions1. To fix this in a way that somewhat resembles the exceptions explained in the original post, we need something unique shared between a raiser and a handler, so that the handler only catches the right exceptions and propagates the rest. In our demo, this is just a matter of creating the exception value ahead of time, in a scope shared between the raiser and handler, and then handling based on object identity. Here’s the fixed AppendAfter:
class AppendAfter(Sequence):
...
def for_each(self, consumer: Callable) -> None:
count = self.amount
# We create the exception value ahead of time. Both the raiser and
# handler have access to it.
sentinel = AppendAfterException()
def limited_consumer(element):
nonlocal count
current = count
count -= 1
if current == 0:
raise sentinel
consumer(element)
try:
self.first.for_each(limited_consumer)
except AppendAfterException as e:
if e is not sentinel:
raise
self.second.for_each(consumer)Full code:
Python implementation with the bug
from collections.abc import Callable
class AppendAfterException(Exception):
pass
class Sequence:
def for_each(self, consumer: Callable) -> None:
raise NotImplementedError
class CountFrom(Sequence):
def __init__(self, start: int):
self.start = start
def for_each(self, consumer: Callable) -> None:
i = self.start
while True:
consumer(i)
i += 1
class Empty(Sequence):
def for_each(self, consumer: Callable) -> None:
pass
class AppendAfter(Sequence):
def __init__(self, first: Sequence, amount: int, second: Sequence):
self.first = first
self.amount = amount
self.second = second
def for_each(self, consumer: Callable) -> None:
count = self.amount
def limited_consumer(element):
nonlocal count
# Note: if you change this to only decrement count when not
# throwing, this works as expected.
#
# The point is, outer AppendAfter's exception is caught by the
# inner AppendAfter, which then leaves inner AppendAfter in an
# invalid state where count is negative.
current = count
count -= 1
if current == 0:
raise AppendAfterException()
consumer(element)
try:
self.first.for_each(limited_consumer)
except AppendAfterException:
pass
self.second.for_each(consumer)
if __name__ == "__main__":
# Works:
AppendAfter(CountFrom(0), 5, Empty()).for_each(print)
# Loops:
AppendAfter(
AppendAfter(CountFrom(0), 10, CountFrom(20)),
5,
Empty()
).for_each(print)Python implementation with the bug fixed
from collections.abc import Callable
class AppendAfterException(Exception):
pass
class Sequence:
def for_each(self, consumer: Callable) -> None:
raise NotImplementedError
class CountFrom(Sequence):
def __init__(self, start: int):
self.start = start
def for_each(self, consumer: Callable) -> None:
i = self.start
while True:
consumer(i)
i += 1
class Empty(Sequence):
def for_each(self, consumer: Callable) -> None:
pass
class AppendAfter(Sequence):
def __init__(self, first: Sequence, amount: int, second: Sequence):
self.first = first
self.amount = amount
self.second = second
def for_each(self, consumer: Callable) -> None:
count = self.amount
sentinel = AppendAfterException()
def limited_consumer(element):
nonlocal count
current = count
count -= 1
if current == 0:
raise sentinel
consumer(element)
try:
self.first.for_each(limited_consumer)
except AppendAfterException as e:
if e is not sentinel:
raise
self.second.for_each(consumer)
if __name__ == "__main__":
# Works:
AppendAfter(CountFrom(0), 5, Empty()).for_each(print)
# Also works now:
AppendAfter(
AppendAfter(CountFrom(0), 10, CountFrom(20)),
5,
Empty()
).for_each(print)If you want to experiment with this in other languages:
Dart implementation
abstract class Sequence<Element> {
void forEach(void Function(Element) consumer);
}
class CountFrom implements Sequence<int> {
final int from;
CountFrom(this.from);
@override
void forEach(void Function(int) consumer) {
for (int i = from; ; i += 1) {
consumer(i);
}
}
}
class Empty implements Sequence<int> {
@override
void forEach(void Function(int) consumer) {}
}
class AppendAfter<Element> implements Sequence<Element> {
final Sequence<Element> first;
final Sequence<Element> second;
final int amount;
AppendAfter(this.first, this.amount, this.second);
@override
void forEach(void Function(Element) consumer) {
try {
int count = amount;
first.forEach((element) {
if (count-- == 0) {
throw AppendAfterException();
}
consumer(element);
});
} on AppendAfterException {}
second.forEach(consumer);
}
}
class AppendAfterException {}
void main() {
// final simple = AppendAfter(CountFrom(0), 5, Empty());
// simple.forEach((i) => print(i));
final complex = AppendAfter(AppendAfter(CountFrom(0), 10, CountFrom(20)), 5, Empty());
complex.forEach((i) => print(i));
}
Fir implementation
trait Sequence[seq, t, exn]:
forEach(self: seq, consumer: Fn(t) / exn) / exn
# ------------------------------------------------------------------------------
type CountFrom(from: U32)
impl Sequence[CountFrom, U32, exn]:
forEach(self: CountFrom, consumer: Fn(U32) / exn) / exn:
let i = self.from
loop:
consumer(i)
i += 1
# ------------------------------------------------------------------------------
type AppendAfter[s1, s2](
seq1: s1,
seq2: s2,
amt: U32,
)
type AppendAfterStop:
AppendAfterStop
impl[Sequence[s1, t, [AppendAfterStop, ..exn]], Sequence[s2, t, [AppendAfterStop, ..exn]]]
Sequence[AppendAfter[s1, s2], t, [AppendAfterStop, ..exn]]:
forEach(
self: AppendAfter[s1, s2],
consumer: Fn(t) / [AppendAfterStop, ..exn]
) / [AppendAfterStop, ..exn]:
match try(\():
self.seq1.forEach(\(i: t) / [AppendAfterStop, ..exn]:
let current = self.amt
self.amt -= 1
if current == 0:
throw(~AppendAfterStop.AppendAfterStop)
consumer(i))):
Result.Ok(()) | Result.Err(~AppendAfterStop.AppendAfterStop):
self.seq2.forEach(consumer)
# ------------------------------------------------------------------------------
type EmptySeq:
EmptySeq
impl Sequence[EmptySeq, t, exn]:
forEach(self: EmptySeq, consumer: Fn(t) / exn) / exn:
()
# ------------------------------------------------------------------------------
main():
let seq =
AppendAfter(
seq1 = AppendAfter(seq1 = CountFrom(from = 0), seq2 = CountFrom(from = 10), amt = 5),
seq2 = EmptySeq.EmptySeq,
amt = 5,
)
try[(), [AppendAfterStop], []](
\(): seq.forEach(\(i: U32): print(i)))
()
Fir implementation demonstrates that the issue is not a typing issue: it happens even with checked exceptions.
Note that in debug builds this Fir program will crash because of an underflow: the counter goes below 0 as explained above, but it’s not allowed to, as the counter type is unsigned. If you want it to loop, run in release mode.
I’ve briefly looked into how exceptions work in SML as the original post mentions it a few times. In SML you can catch all exceptions, so you can intercept anything and it doesn’t fully implement Robert’s ideal exception semantics.↩︎