Handling Event Failures
An asynchronous request can fail after the function that started it has returned. You need a place to observe that failure and a way to finish the task whose result will no longer arrive. This page explains how source errors, callback exceptions, and loop policies fit together, then shows how managed workers release a caller that is waiting for results. It builds on Connecting to Event Sources; the first local examples need no worker threads.
API Call Failures, Operation Errors, and Callback Exceptions
Consider a lookup started by a queued callback.
An invalid argument can make start() throw during that call.
A correctly started lookup can later report an operation failure through onError().
Finally, your own result or error callback can throw while processing a notification.
These failures have different observers:
Failure location |
Observer |
Typical response |
|---|---|---|
API call or configuration |
The caller’s |
Correct the input or abandon setup. |
Asynchronous operation |
The source’s error callback |
Report a failed result, retry, or finish the task. |
Invocation or event-dispatch callback |
The event loop’s error policy or handler |
Record the exception or end the affected work. |
A try and catch around invoke() can observe failures of the submission itself.
It cannot catch an exception thrown later by the submitted callback: that callback runs on a different call stack,
possibly on a different thread.
Likewise, a source’s onError() is not a catch-all handler for exceptions from your callbacks.
An uncaught setup exception inside an invocation also reaches the loop’s callback-error boundary.
Operation Errors and Task Completion
An error notification gives your controller a chance to decide what the failed operation means. A lookup failure might end one request while the rest of the application continues. A required startup lookup might instead prevent the application from doing useful work. In either case, record a terminal outcome or start a bounded recovery attempt; leaving the caller waiting for a success notification loses the completion path. Building Workflows Across Callbacks shows how success and failure callbacks determine whether an operation continues to its next step or ends.
The lookup demo below chooses application failure.
It retains the lookup in lookupData and installs its handler before starting the operation on the application’s main
loop.
The handler converts the source’s structured error context into a
NetworkError so that the application’s diagnostic boundary can report it:
network/HostLookupFailure/main.cpp
struct LookupData {
el::HostLookupPtr lookup;
} lookupData;
void resolveMissingHost() {
const auto events = el::application().events();
lookupData.lookup = events->get<el::Network>().createHostLookup();
lookupData.lookup->events().onError(
[](const el::NetworkErrorContext &context) -> void { throw el::NetworkError{context}; });
lookupData.lookup->start(el::Host::fromStringOrThrow("stelling.invalid"_el));
}
The surrounding application queues resolveMissingHost() through app.events()->invoke() before app.run().
This is the same owner-loop setup used in Connecting to Event Sources.
The .invalid name supplies the failure case; resolver timing and diagnostic details depend on the system.
The local callback demos below provide deterministic examples of exception handling without a resolver.
Throwing here deliberately changes an operation error into a callback exception.
For local recovery, handle the context and return normally instead.
For this particular lookup API, a throwing result or error handler skips onFinal().
Do not put indispensable cleanup solely in that final callback: retain resources through their required lifetime and use
destructors for unconditional cleanup.
See Resolving Hosts Asynchronously for the source’s completion and cancellation contract.
Reporting Captured Callback Failures
A directly owned EventLoop stores callback exceptions by default and continues
dispatching.
This is useful when a procedural host wants to inspect a finite batch after dispatch, but continuing does not mean the
failed callbacks succeeded.
The example explicitly selects EventLoopErrorPolicy::Store through
EventLoopOptions to make that choice visible.
Options are copied when the loop is created; changing the original options later does not reconfigure it.
Here, two route callbacks throw and a third marks its own completion.
runUntilIdle() dispatches the immediately available work on the calling thread.
There are no delayed operations or producers in this example, so the three submissions form a finite batch.
In a program with timers or external sources, becoming idle alone would not prove task completion.
event/EventFailures/CapturedErrors.cpp
void capturedErrors() {
auto options = el::EventLoopOptions{};
options.setErrorPolicy(el::EventLoopErrorPolicy::Store);
const auto loop = el::EventLoop::create(options);
auto completed = false;
loop->invoke([]() -> void { throw el::RuntimeError{"Route data unavailable: 地球 → 火星"_el}; });
loop->invoke([]() -> void { throw el::RuntimeError{"Route data unavailable: 地球 → 金星"_el}; });
loop->invoke([&completed]() -> void { completed = true; });
const auto processed = loop->runUntilIdle();
// Consume failures in capture order at the reporting boundary.
auto reported = 0;
while (const auto error = loop->takeError()) {
try {
std::rethrow_exception(error);
} catch (const el::RuntimeError &failure) {
el::io::printLine(failure.reason());
++reported;
}
}
if (processed != 3 || !completed || reported != 2 || loop->hasError()) {
throw el::LogicFailure{"Expected two reported errors and a completed final callback."_el};
}
el::io::printLine("Final callback ran; both route failures were reported."_el);
}
Output
Route data unavailable: 地球 → 火星
Route data unavailable: 地球 → 金星
Final callback ran; both route failures were reported.
The local completed variable stays alive throughout dispatch.
Its value proves only that the third callback ran; the two earlier failures still need reporting.
hasError() tests whether captured failures remain.
takeError() removes the oldest exception and returns an empty pointer when the captured queue is empty.
The loop above consumes failures in capture order and rethrows each at a reporting boundary that understands the
expected RuntimeError.
Unexpected exception types propagate to the demo application’s outer boundary.
In your own host, choose where that boundary belongs: after a finite dispatch batch, after joining a worker, or in an explicit controller failure path. If failures can continue arriving, arrange regular consumption; the stored-error queue has no capacity limit. For direct dispatch and unmanaged thread ownership, see Driving Event Loops Directly.
Continuing, Quitting, or Failing the Application
Choose the default errorPolicy() according to whether the
remaining work is independent of the failed callback.
Store preserves diagnostic evidence while letting other work proceed.
Ignore continues without retaining it, which is appropriate only when losing that evidence is deliberate.
Neither policy supplies a missing result or repairs partially changed application state.
If an exception invalidates the current task, Quit stores it, closes admission, and drains callbacks already
accepted by the loop.
This is graceful termination, so an already queued callback can still run after the failure.
It must not require a new reply or timer callback to be admitted after that point.
Finish asynchronous resource shutdown before quitting its owner loop, as explained in Cancelling Work and Shutting Down.
For an application-managed worker, FailApplication forwards the stored exception to its owning application and
starts application failure handling.
Standalone EventLoop and UnmanagedEventThread factories reject
that policy with ParameterFailure because there is no owning application
to receive the failure.
The same restriction applies to the queue-overflow policy of that name.
The Queue Capacity and Error Policies reference lists the exact policy contracts.
Custom Callback Failure Handlers
Install an EventLoopErrorHandler with
setErrorHandler() when the response needs a decision beyond
the fixed policies.
The loop stores the exception before calling the handler on the dispatching thread.
The handler returns EventLoopErrorAction::Continue or
EventLoopErrorAction::Stop.
Stop ends dispatch without draining the remaining queue; it does not undo the failed callback’s side effects.
This variation stops before the second callback and reports the retained exception after dispatch returns:
event/EventFailures/ErrorHandler.cpp
void errorHandler() {
const auto loop = el::EventLoop::create();
auto handled = false;
auto nextCallbackRan = false;
loop->setErrorHandler([&handled](std::exception_ptr error) -> el::EventLoopErrorAction {
handled = error != nullptr;
return el::EventLoopErrorAction::Stop;
});
loop->invoke([]() -> void { throw el::RuntimeError{"Route calculation failed: 地球 → 火星"_el}; });
loop->invoke([&nextCallbackRan]() -> void { nextCallbackRan = true; });
const auto processed = loop->runUntilIdle();
const auto error = loop->takeError();
if (processed != 1 || !handled || nextCallbackRan || !error) {
throw el::LogicFailure{"Expected a captured error and stopped dispatch."_el};
}
try {
std::rethrow_exception(error);
} catch (const el::RuntimeError &failure) {
el::io::printLine(failure.reason());
}
loop->setErrorHandler({}); // Restore the configured default policy.
el::io::printLine("Dispatch stopped before the next callback."_el);
}
Output
Route calculation failed: 地球 → 火星
Dispatch stopped before the next callback.
The handler captures only local state that remains alive while the loop runs.
It replaces the configured default action, even if that action was Ignore or FailApplication.
Clearing it with setErrorHandler({}) restores the configured policy; it does not restart the loop or drain pending
work.
The example ends without dispatching the retained second callback.
Keep an error handler small and reliable. If it throws, the loop captures that additional exception and stops. The original exception is already stored, so reporting should account for both failures rather than assuming there can only be one.
Releasing a Caller Waiting for Worker Results
The file hasher in Processing Work on Event Threads uses workers to compute hashes and a separate receiver to
collect results.
Its procedural main waits for that receiver with join().
If a worker throws before posting its result, the receiver’s success count can never reach the expected total.
Storing the exception on the worker alone would leave main waiting.
The demo configures both the receiver and workers with FailApplication:
event/FileHasher/FileHasherApp.hpp
void startLoops() {
/// Prepare the result receiver.
auto options = el::EventLoopOptions{}.setErrorPolicy(el::EventLoopErrorPolicy::FailApplication);
_resultReceiver = createEventThread(options);
_resultReceiver->start();
/// Reserve either enough workers for all CPU cores or for every file if there are less.
const auto count = std::min<std::size_t>(el::system::info::logicalCpuCount(), _paths.count().toSizeT());
_workers.reserve(count);
for (auto index = std::uint32_t{0}; index < count; ++index) {
auto worker = createEventThread(options);
_workers.push_back(worker);
worker->start();
}
}
On failure, the application preserves its first error, stops normal main-loop dispatch, and requests graceful
termination of all its managed threads, including the receiver.
That releases main’s join even when main is procedural and never entered its own event loop.
After shutdown processing and worker joins, the application rethrows the retained failure on the application thread.
Core exceptions use its diagnostic and exit-code handling; foreign exceptions run cleanup and escape
Application::run().
join() itself transports no exceptions.
See Building Procedural Applications for the surrounding lifecycle.
This policy handles thrown failures, not a source that silently never replies. A deadline or another explicit cancellation path is still needed for missing results, and no loop policy can interrupt a callback that never returns.
Complete demo sources
network/HostLookupFailure/main.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include <DemoTerminal.hpp>
#include <erbsland/all_core.hpp>
#include <erbsland/all_event.hpp>
#include <erbsland/all_network.hpp>
#include <erbsland/all_network_host_lookup.hpp>
#include <erbsland/all_network_source.hpp>
#include <erbsland/all_stream.hpp>
#include <erbsland/all_text.hpp>
#include <erbsland/Application.hpp>
#include <erbsland/Host.hpp>
#include <erbsland/HostLookup.hpp>
#include <erbsland/Network.hpp>
#include <erbsland/NetworkError.hpp>
#include <erbsland/NetworkErrorContext.hpp>
#include <erbsland/PlatformErrorContext.hpp>
namespace demo {
using namespace el::text::literals;
struct LookupData {
el::HostLookupPtr lookup;
} lookupData;
/// Resolver failures arrive through `onError()` as a structured context.
/// Throwing `NetworkError` transfers that context to the application's standard diagnostic boundary.
void resolveMissingHost() {
const auto events = el::application().events();
lookupData.lookup = events->get<el::Network>().createHostLookup();
lookupData.lookup->events().onError(
[](const el::NetworkErrorContext &context) -> void { throw el::NetworkError{context}; });
lookupData.lookup->start(el::Host::fromStringOrThrow("stelling.invalid"_el));
}
auto main(const int argc, char *argv[]) -> int {
auto app = el::Application{argc, argv};
demo::DemoTerminal::enable(app);
app.events()->invoke(resolveMissingHost);
return app.run();
}
}
auto main(const int argc, char *argv[]) -> int {
return demo::main(argc, argv);
}
event/EventFailures/CapturedErrors.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "EventFailuresDemos.hpp"
#include <erbsland/EventLoop.hpp>
#include <erbsland/EventLoopErrorPolicy.hpp>
#include <erbsland/EventLoopOptions.hpp>
#include <erbsland/LogicFailure.hpp>
#include <erbsland/RuntimeError.hpp>
#include <erbsland/StandardStreams.hpp>
#include <exception>
namespace demo {
/// Stored callback failures can be reported after a finite batch has dispatched.
void capturedErrors() {
auto options = el::EventLoopOptions{};
options.setErrorPolicy(el::EventLoopErrorPolicy::Store);
const auto loop = el::EventLoop::create(options);
auto completed = false;
loop->invoke([]() -> void { throw el::RuntimeError{"Route data unavailable: 地球 → 火星"_el}; });
loop->invoke([]() -> void { throw el::RuntimeError{"Route data unavailable: 地球 → 金星"_el}; });
loop->invoke([&completed]() -> void { completed = true; });
const auto processed = loop->runUntilIdle();
// Consume failures in capture order at the reporting boundary.
auto reported = 0;
while (const auto error = loop->takeError()) {
try {
std::rethrow_exception(error);
} catch (const el::RuntimeError &failure) {
el::io::printLine(failure.reason());
++reported;
}
}
if (processed != 3 || !completed || reported != 2 || loop->hasError()) {
throw el::LogicFailure{"Expected two reported errors and a completed final callback."_el};
}
el::io::printLine("Final callback ran; both route failures were reported."_el);
}
}
event/EventFailures/ErrorHandler.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "EventFailuresDemos.hpp"
#include <erbsland/EventLoop.hpp>
#include <erbsland/EventLoopErrorAction.hpp>
#include <erbsland/LogicFailure.hpp>
#include <erbsland/RuntimeError.hpp>
#include <erbsland/StandardStreams.hpp>
#include <exception>
namespace demo {
/// A custom handler can stop dispatch immediately after a callback failure.
void errorHandler() {
const auto loop = el::EventLoop::create();
auto handled = false;
auto nextCallbackRan = false;
loop->setErrorHandler([&handled](std::exception_ptr error) -> el::EventLoopErrorAction {
handled = error != nullptr;
return el::EventLoopErrorAction::Stop;
});
loop->invoke([]() -> void { throw el::RuntimeError{"Route calculation failed: 地球 → 火星"_el}; });
loop->invoke([&nextCallbackRan]() -> void { nextCallbackRan = true; });
const auto processed = loop->runUntilIdle();
const auto error = loop->takeError();
if (processed != 1 || !handled || nextCallbackRan || !error) {
throw el::LogicFailure{"Expected a captured error and stopped dispatch."_el};
}
try {
std::rethrow_exception(error);
} catch (const el::RuntimeError &failure) {
el::io::printLine(failure.reason());
}
loop->setErrorHandler({}); // Restore the configured default policy.
el::io::printLine("Dispatch stopped before the next callback."_el);
}
}
event/FileHasher/FileHasherApp.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "HashResult.hpp"
#include <erbsland/all_core.hpp>
#include <erbsland/all_event.hpp>
#include <erbsland/all_options.hpp>
#include <erbsland/all_path.hpp>
#include <erbsland/all_stream.hpp>
#include <erbsland/all_text.hpp>
#include <erbsland/Application.hpp>
#include <erbsland/EventLoopErrorPolicy.hpp>
#include <erbsland/EventLoopOptions.hpp>
#include <erbsland/ExitCode.hpp>
#include <erbsland/ItemIndex.hpp>
#include <erbsland/ManagedEventThread.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/system/SystemInfo.hpp>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <utility>
#include <vector>
namespace demo {
using namespace el::text::literals;
/// Combine a procedural main with worker loops and one result-receiver loop.
/// Only the receiver updates results while workers run; main reads them after joining it.
class FileHasherApp final : public el::Application {
/// The identifier for the directory command line option.
constexpr static auto cDirectoryOption = "directory"_el;
public:
using Application::Application;
protected: // implement Application
void initialize() override;
void registerCommandLineOptions(const el::OptionsPtr &options) override;
void handleCommandLineOptions(const erbsland::OptionValuesPtr &values) override;
[[nodiscard]] auto main() -> el::ExitCode override {
collectFiles();
if (_totalFiles == 0) {
el::io::printLine("No files to hash."_el);
return el::ExitCode::success();
}
startLoops();
el::io::printLine("Hashing "_el, _totalFiles, " files with "_el, _workers.size(), " worker loops."_el);
distributePaths();
_resultReceiver->join();
if (_completedFiles != _totalFiles) {
return el::ExitCode::failure();
}
printResults();
return el::ExitCode::success();
}
private:
/// Start all worker loops and the receiver loop.
void startLoops() {
/// Prepare the result receiver.
auto options = el::EventLoopOptions{}.setErrorPolicy(el::EventLoopErrorPolicy::FailApplication);
_resultReceiver = createEventThread(options);
_resultReceiver->start();
/// Reserve either enough workers for all CPU cores or for every file if there are less.
const auto count = std::min<std::size_t>(el::system::info::logicalCpuCount(), _paths.count().toSizeT());
_workers.reserve(count);
for (auto index = std::uint32_t{0}; index < count; ++index) {
auto worker = createEventThread(options);
_workers.push_back(worker);
worker->start();
}
}
/// Distribute the collected paths to all worker loops.
void distributePaths() {
for (auto index = el::ItemIndex{}; index.isWithin(_paths.count()); ++index) {
_workers[index.toSizeT() % _workers.size()]->events()->invoke([this, path = _paths.get(index)]() -> void {
auto result = hashFile(path);
_resultReceiver->events()->invoke(
[this, result = std::move(result)]() mutable -> void { addResult(std::move(result)); });
});
}
}
/// Add a hash result to the results list and display progress.
void addResult(HashResult result) {
_results.push_back(std::move(result));
++_completedFiles;
if (_completedFiles % 10U == 0) {
el::io::print("."_el);
el::stdOut()->flush();
}
if (_completedFiles == _totalFiles) {
_resultReceiver->quit();
}
}
/// Collect all paths from the selected root path.
void collectFiles();
/// Print the results.
void printResults();
/// Collect a list of files to process by recursive traversing the given directory.
static auto collectFiles(const el::Path &root) -> el::PathList;
/// Compute the hash result for a single file.
static auto hashFile(const el::Path &path) -> HashResult;
private:
std::vector<el::ManagedEventThreadPtr> _workers; ///< Workers retained throughout the batch.
el::ManagedEventThreadPtr _resultReceiver; ///< Sole owner of mutable result state while running.
el::Path _root; ///< The root path to be scanned.
el::PathList _paths; ///< The list of paths to process.
std::size_t _totalFiles{0}; ///< Fixed before any thread starts.
std::size_t _completedFiles{0}; ///< Updated only on the receiver thread.
std::vector<HashResult> _results; ///< Read by main only after the receiver is joined.
};
}