Dynamic number of runnable tasks
Perhaps I got off track somehow, but I found run() and
_work() difficult to understand. I was puzzled by the decision to use
two methods rather than one, I did not immediately grasp why two layers
of exception handling were needed, and the intent behind
the while True loop in run() wasn't immediately obvious.
Here are a few possible edits intended to simplify and clarify. Unless
I overlooked a detail, the edits:

do little more than move your
lines of code around, putting everything under run() other than
the completely separable stuff moved to _drain_queue();
rename
result to work_items for clarity; and
add a few short comments to
provide some simple narrative guidance to any future code readers.


def run(self):
    while True:
        # Get next work item, stopping on the sentinel.
        item = self._queue.get()
        if item is END_ITEM:
            break

        # Run the handler, enqueuing any work items it yields.
        try:
            work_items = self._handler(item.value)
            if work_items:
                for item in work_items:
                    self._queue.put(WorkItem(item))

        # On error, store exception and drain queue.
        except Exception as e:
            self._exception = e
            self._drain_queue()

        # Either way, mark current task done.
        finally:
            self._queue.task_done()

def _drain_queue(self):
    while True:
        try:
            self._queue.get(False)
        except queue.Empty:
            break
        else:
            self._queue.task_done()