Skip to content

Commit 2f7b4ea

Browse files
committed
Merge TASK-031: Handler error-propagation contract (DR-009)
2 parents 908f5c2 + ea63ffe commit 2f7b4ea

10 files changed

Lines changed: 767 additions & 42 deletions

File tree

specs/tasks/M5-routing-lifecycle/TASK-031.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88
Implement the 6-point error-propagation contract from §5.2 / DR-009 in the dispatch path so any uncaught exception lands at the configured `internal_error_handler` with documented behavior.
99

1010
**Action Items:**
11-
- [ ] Wrap handler invocation in dispatch with `try { ... } catch (const std::exception& e) { ... } catch (...) { ... }`.
12-
- [ ] On `std::exception`: log via `error_logger` (whatever callback the user wired), invoke `internal_error_handler` with `e.what()`, send the resulting response (default 500 if no handler set).
13-
- [ ] On non-`std::exception`: same path but with message `"unknown exception"`.
14-
- [ ] If `internal_error_handler` itself throws: log generically, send hardcoded 500 with empty body.
15-
- [ ] `feature_unavailable` is a `std::runtime_error`; no special status mapping (just lands as a 500 like any other exception).
16-
- [ ] Document the contract in `webserver.hpp` Doxygen comments (full README pass in M6).
11+
- [x] Wrap handler invocation in dispatch with `try { ... } catch (const std::exception& e) { ... } catch (...) { ... }`.
12+
- [x] On `std::exception`: log via `error_logger` (whatever callback the user wired), invoke `internal_error_handler` with `e.what()`, send the resulting response (default 500 if no handler set).
13+
- [x] On non-`std::exception`: same path but with message `"unknown exception"`.
14+
- [x] If `internal_error_handler` itself throws: log generically, send hardcoded 500 with empty body.
15+
- [x] `feature_unavailable` is a `std::runtime_error`; no special status mapping (just lands as a 500 like any other exception).
16+
- [x] Document the contract in `webserver.hpp` Doxygen comments (full README pass in M6).
1717

1818
**Dependencies:**
1919
- Blocked by: TASK-027, TASK-030
@@ -29,4 +29,4 @@ Implement the 6-point error-propagation contract from §5.2 / DR-009 in the disp
2929
**Related Requirements:** PRD-FLG-REQ-002 (sentinel/throw behavior)
3030
**Related Decisions:** DR-009, §5.2, AR-007
3131

32-
**Status:** Not Started
32+
**Status:** Done

specs/tasks/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Nominally: **13 sequential tasks**, each S–XL. Most other tasks parallelize of
113113
| TASK-028 | Routing-semantics regression gate | M5 | Done | TASK-027 |
114114
| TASK-029 | Naming consistency — `stop_and_wait`, `block_ip`/`unblock_ip` | M5 | Done | TASK-014 |
115115
| TASK-030 | `_handler` suffix renames + `explicit` constructor | M5 | Done | TASK-014 |
116-
| TASK-031 | Handler error-propagation contract (DR-009) | M5 | Not Started | TASK-027, TASK-030 |
116+
| TASK-031 | Handler error-propagation contract (DR-009) | M5 | Done | TASK-027, TASK-030 |
117117
| TASK-032 | Thread-safety contract stress test (DR-008) | M5 | Not Started | TASK-027, TASK-031 |
118118
| TASK-033 | `create_webserver` builder cleanup | M5 | Not Started | TASK-006, TASK-014 |
119119
| TASK-034 | Build-flag-independent public API + `webserver::features()` | M5 | Not Started | TASK-003, TASK-019, TASK-033 |

specs/unworked_review_issues/2026-05-11_225150_task-031.md

Lines changed: 188 additions & 0 deletions
Large diffs are not rendered by default.

src/httpserver/create_webserver.hpp

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
#include <functional>
3131
#include <limits>
3232
#include <string>
33+
#include <string_view>
3334
#include <utility>
3435
#include <variant>
3536
#include <vector>
@@ -43,10 +44,19 @@ namespace httpserver {
4344
class webserver;
4445
class http_request;
4546

46-
// TASK-030 / PRD-NAM-REQ-003: the three error-page setters take a function-
47-
// shaped handler that returns http_response by value, matching the on_*
48-
// family (detail::lambda_handler) introduced in TASK-025.
47+
// TASK-030 / PRD-NAM-REQ-003: the not_found_handler and method_not_allowed_handler
48+
// setters take a function-shaped handler that returns http_response by value,
49+
// matching the on_* family (detail::lambda_handler) introduced in TASK-025.
4950
typedef std::function<http_response(const http_request&)> error_handler;
51+
52+
// TASK-031 / DR-009 §5.2: internal_error_handler receives a second argument
53+
// carrying the originating exception's message — `e.what()` for
54+
// std::exception, the literal "unknown exception" for non-std throws. The
55+
// message is a non-owning view; callers MUST NOT capture it past the
56+
// handler's return (it points into either a caught std::exception's
57+
// internal storage or a fixed string literal owned by libhttpserver).
58+
typedef std::function<http_response(const http_request&, std::string_view message)> internal_error_handler_t;
59+
5060
typedef std::function<bool(const std::string&)> validator_ptr;
5161
typedef std::function<void(const std::string&)> log_access_ptr;
5262
typedef std::function<void(const std::string&)> log_error_ptr;
@@ -380,7 +390,34 @@ class create_webserver {
380390
return *this;
381391
}
382392

383-
create_webserver& internal_error_handler(error_handler handler) {
393+
/**
394+
* Set the handler invoked when a registered request handler throws
395+
* an uncaught exception.
396+
*
397+
* Per DR-009 / §5.2 (PRD-FLG-REQ-002), the dispatch path wraps every
398+
* handler call in a two-branch try/catch and funnels here:
399+
*
400+
* - `std::exception` -> @p handler is invoked with
401+
* `e.what()` as the message argument.
402+
* - non-std::exception -> @p handler is invoked with the
403+
* literal string `"unknown exception"`.
404+
* - @p handler unset -> a default 500 with the message in
405+
* the response body is sent and the
406+
* event is logged via log_error.
407+
* - @p handler itself throws -> a hardcoded 500 with an EMPTY body
408+
* is sent and the event is logged via
409+
* log_error.
410+
*
411+
* `feature_unavailable` is a `std::runtime_error` subclass: it lands
412+
* here with the default 500 status, not a special 503.
413+
*
414+
* Note: @p handler may be invoked concurrently from multiple MHD
415+
* worker threads. It MUST be thread-safe.
416+
*
417+
* @param handler invoked with (request, message); returns the
418+
* http_response to send (status / headers / body).
419+
**/
420+
create_webserver& internal_error_handler(internal_error_handler_t handler) {
384421
_internal_error_handler = std::move(handler);
385422
return *this;
386423
}
@@ -528,7 +565,7 @@ class create_webserver {
528565
bool _tcp_nodelay = false;
529566
error_handler _not_found_handler = nullptr;
530567
error_handler _method_not_allowed_handler = nullptr;
531-
error_handler _internal_error_handler = nullptr;
568+
internal_error_handler_t _internal_error_handler = nullptr;
532569
file_cleanup_callback_ptr _file_cleanup_callback = nullptr;
533570
auth_handler_ptr _auth_handler = nullptr;
534571
std::vector<std::string> _auth_skip_paths;

src/httpserver/detail/webserver_impl.hpp

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
#include <set>
5454
#include <shared_mutex>
5555
#include <string>
56+
#include <string_view>
5657
#include <unordered_map>
5758
#include <utility>
5859
#include <vector>
@@ -305,8 +306,43 @@ class webserver_impl {
305306
// owning webserver (via `parent`).
306307
std::shared_ptr<::httpserver::http_response> not_found_page(modded_request* mr) const;
307308
std::shared_ptr<::httpserver::http_response> method_not_allowed_page(modded_request* mr) const;
308-
std::shared_ptr<::httpserver::http_response> internal_error_page(modded_request* mr,
309-
bool force_our = false) const;
309+
// TASK-031: error-propagation entry point. @p msg carries the
310+
// originating exception's text (e.what() for std::exception, the
311+
// sentinel "unknown exception" for non-std throws, or a fixed
312+
// internal-diagnostic string for the few non-handler-throw call
313+
// sites that synthesise a 500 with no exception in flight).
314+
//
315+
// Behaviour matches DR-009:
316+
// - parent->internal_error_handler set, !force_our:
317+
// invoke it with (*mr->dhr, msg) and return the result.
318+
// - force_our=true: return a hardcoded 500 with an EMPTY body
319+
// (the "double-fault" fallback used when the user handler
320+
// itself threw).
321+
// - otherwise (no handler set, !force_our): return a default
322+
// 500 whose body surfaces @p msg, so the unset-handler
323+
// default is informative.
324+
//
325+
// Throws nothing on its own; if parent->internal_error_handler
326+
// throws, that exception propagates to the caller. Callers in the
327+
// handler-throw path use run_internal_error_handler_safely() to
328+
// contain that double-fault.
329+
std::shared_ptr<::httpserver::http_response> internal_error_page(
330+
modded_request* mr,
331+
std::string_view msg,
332+
bool force_our = false) const;
333+
334+
// TASK-031: log @p msg via parent->log_error if a logger is configured.
335+
// Swallows any exception thrown by the logger -- dispatch must never
336+
// re-enter the catch from inside its own catch.
337+
void log_dispatch_error(std::string_view msg) const;
338+
339+
// TASK-031: invoke the user-supplied internal_error_handler safely.
340+
// On success, returns the response it produced. If the user handler
341+
// itself throws, logs generically via log_dispatch_error and returns
342+
// a hardcoded empty-body 500.
343+
std::shared_ptr<::httpserver::http_response>
344+
run_internal_error_handler_safely(modded_request* mr,
345+
std::string_view msg) const;
310346
bool should_skip_auth(const std::string& path) const;
311347
void invalidate_route_cache();
312348

src/httpserver/webserver.hpp

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,32 @@ namespace httpserver {
7676

7777
/**
7878
* Class representing the webserver. Main class of the apis.
79+
*
80+
* ### Handler error-propagation contract (DR-009 / §5.2 / PRD-FLG-REQ-002)
81+
*
82+
* Every registered request handler is invoked from the dispatch path under
83+
* a two-branch try/catch. The contract:
84+
*
85+
* 1. The handler call is wrapped in
86+
* `try { ... } catch (const std::exception& e) { ... } catch (...) { ... }`.
87+
* 2. On `std::exception`: the message is logged via the configured
88+
* `log_error` callback, then `internal_error_handler` is invoked with
89+
* `e.what()`. The response it returns is sent on the wire (default
90+
* 500 with the message in the body when no handler is configured).
91+
* 3. On non-`std::exception` (e.g. `throw 42`): same path with the
92+
* message replaced by the literal string `"unknown exception"`.
93+
* 4. If `internal_error_handler` itself throws while servicing 2 or 3,
94+
* the failure is logged generically and a hardcoded 500 with an
95+
* EMPTY body is sent. No exception ever escapes into libmicrohttpd.
96+
* 5. `feature_unavailable` (a `std::runtime_error` subclass) is NOT
97+
* mapped to a special status: it lands as a generic 500 like any
98+
* other `std::exception`.
99+
* 6. The `log_error` callback may be invoked concurrently from multiple
100+
* MHD worker threads; user implementations MUST be thread-safe.
101+
*
102+
* The contract is the single source of truth for dispatch-time exception
103+
* handling; resource implementations are encouraged to throw rather than
104+
* synthesise an http_response with a 500 status.
79105
**/
80106
class webserver {
81107
public:
@@ -468,7 +494,7 @@ class webserver {
468494
const bool tcp_nodelay;
469495
const error_handler not_found_handler;
470496
const error_handler method_not_allowed_handler;
471-
const error_handler internal_error_handler;
497+
const internal_error_handler_t internal_error_handler;
472498
const file_cleanup_callback_ptr file_cleanup_callback;
473499
const auth_handler_ptr auth_handler;
474500
const std::vector<std::string> auth_skip_paths;

src/webserver.cpp

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,14 +1610,60 @@ std::shared_ptr<http_response> webserver_impl::method_not_allowed_page(detail::m
16101610
}
16111611
}
16121612

1613-
std::shared_ptr<http_response> webserver_impl::internal_error_page(detail::modded_request* mr, bool force_our) const {
1614-
if (parent->internal_error_handler != nullptr && !force_our) {
1615-
return std::make_shared<http_response>(parent->internal_error_handler(*mr->dhr));
1616-
} else {
1613+
std::shared_ptr<http_response> webserver_impl::internal_error_page(
1614+
detail::modded_request* mr,
1615+
std::string_view msg,
1616+
bool force_our) const {
1617+
// TASK-031 / DR-009 §5.2 point 4: the double-fault fallback. Used when
1618+
// the user-supplied internal_error_handler itself threw or when the
1619+
// belt-and-suspenders site after get_raw_response_with_fallback fires.
1620+
// The body is intentionally empty and the message is intentionally
1621+
// ignored.
1622+
if (force_our) {
16171623
return std::make_shared<http_response>(
1618-
http_response::string(std::string{constants::GENERIC_ERROR})
1624+
http_response::empty()
16191625
.with_status(http_utils::http_internal_server_error));
16201626
}
1627+
// §5.2 point 2/3: invoke the user handler with the originating message.
1628+
if (parent->internal_error_handler != nullptr) {
1629+
return std::make_shared<http_response>(
1630+
parent->internal_error_handler(*mr->dhr, msg));
1631+
}
1632+
// No handler configured: surface the message in the default body so
1633+
// the unset-handler path is informative for debugging. Operators who
1634+
// need a fixed body can wire a constant-returning handler.
1635+
return std::make_shared<http_response>(
1636+
http_response::string(std::string{msg})
1637+
.with_status(http_utils::http_internal_server_error));
1638+
}
1639+
1640+
void webserver_impl::log_dispatch_error(std::string_view msg) const {
1641+
if (parent->log_error == nullptr) {
1642+
return;
1643+
}
1644+
// A misbehaving user logger must not poison the catch from inside the
1645+
// catch. Swallow any exception it throws; we have no recovery beyond
1646+
// dropping the log line.
1647+
try {
1648+
parent->log_error(std::string(msg));
1649+
} catch (...) {
1650+
// Intentionally suppressed.
1651+
}
1652+
}
1653+
1654+
std::shared_ptr<http_response>
1655+
webserver_impl::run_internal_error_handler_safely(
1656+
detail::modded_request* mr,
1657+
std::string_view msg) const {
1658+
try {
1659+
return internal_error_page(mr, msg, /*force_our=*/false);
1660+
} catch (...) {
1661+
// §5.2 point 4: the user handler itself threw. Log generically
1662+
// and return an empty-body 500. No exception escapes from here.
1663+
log_dispatch_error("internal_error_handler threw; "
1664+
"sending hardcoded empty-body 500");
1665+
return internal_error_page(mr, "", /*force_our=*/true);
1666+
}
16211667
}
16221668

16231669
void webserver_impl::invalidate_route_cache() {
@@ -1871,7 +1917,11 @@ struct MHD_Response* webserver_impl::get_raw_response_with_fallback(detail::modd
18711917
try {
18721918
struct MHD_Response* raw = materialize_response(mr->dhrs.get());
18731919
if (raw == nullptr) {
1874-
mr->dhrs = internal_error_page(mr);
1920+
// TASK-031: no exception was thrown, but the body materializer
1921+
// returned null. Route through the safe internal-error path so
1922+
// a misbehaving user handler can't escape.
1923+
mr->dhrs = run_internal_error_handler_safely(
1924+
mr, "materialize_response returned null");
18751925
raw = materialize_response(mr->dhrs.get());
18761926
}
18771927
return raw;
@@ -1882,9 +1932,19 @@ struct MHD_Response* webserver_impl::get_raw_response_with_fallback(detail::modd
18821932
} catch(...) {
18831933
return nullptr;
18841934
}
1935+
} catch(const std::exception& e) {
1936+
log_dispatch_error(std::string("materialize threw: ") + e.what());
1937+
try {
1938+
mr->dhrs = run_internal_error_handler_safely(mr, e.what());
1939+
return materialize_response(mr->dhrs.get());
1940+
} catch(...) {
1941+
return nullptr;
1942+
}
18851943
} catch(...) {
1944+
log_dispatch_error("materialize threw unknown exception");
18861945
try {
1887-
mr->dhrs = internal_error_page(mr);
1946+
mr->dhrs = run_internal_error_handler_safely(mr,
1947+
"unknown exception");
18881948
return materialize_response(mr->dhrs.get());
18891949
} catch(...) {
18901950
return nullptr;
@@ -2072,7 +2132,12 @@ MHD_Result webserver_impl::finalize_answer(MHD_Connection* connection, struct de
20722132
// shared_ptr has no operator->*, so call as ((*ptr).*pmf)(...).
20732133
mr->dhrs = ((*hrm).*(mr->callback))(*mr->dhr); // copy in memory (move in case)
20742134
if (mr->dhrs.get() == nullptr || mr->dhrs->get_status() == -1) {
2075-
mr->dhrs = internal_error_page(mr);
2135+
// TASK-031: no exception was thrown, but the handler
2136+
// returned a null/invalid response. Route through the
2137+
// safe internal-error path so a misbehaving user
2138+
// handler can't escape into libmicrohttpd.
2139+
mr->dhrs = run_internal_error_handler_safely(
2140+
mr, "handler returned null response");
20762141
}
20772142
} else {
20782143
mr->dhrs = method_not_allowed_page(mr);
@@ -2096,23 +2161,31 @@ MHD_Result webserver_impl::finalize_answer(MHD_Connection* connection, struct de
20962161
mr->dhrs->with_header(http_utils::http_header_allow, header_value);
20972162
}
20982163
}
2099-
} catch(...) {
2100-
// The user-supplied internal_error_handler may itself throw;
2101-
// fall back to the built-in error page in that case (force_our=true)
2102-
// so we never let exceptions escape into libmicrohttpd.
2103-
try {
2104-
mr->dhrs = internal_error_page(mr);
2105-
} catch(...) {
2106-
mr->dhrs = internal_error_page(mr, true);
2107-
}
2164+
} catch (const std::exception& e) {
2165+
// TASK-031 / DR-009 §5.2 point 2: handler threw std::exception.
2166+
// Log via error_logger, forward e.what() to internal_error_handler.
2167+
// run_internal_error_handler_safely contains a possible
2168+
// re-throw from the user handler (point 4).
2169+
log_dispatch_error(std::string("dispatch: handler threw "
2170+
"std::exception: ") + e.what());
2171+
mr->dhrs = run_internal_error_handler_safely(mr, e.what());
2172+
} catch (...) {
2173+
// §5.2 point 3: handler threw non-std::exception. Same flow
2174+
// as the std::exception arm but with the sentinel message.
2175+
log_dispatch_error("dispatch: handler threw unknown exception");
2176+
mr->dhrs = run_internal_error_handler_safely(mr,
2177+
"unknown exception");
21082178
}
21092179
} else if (mr->dhrs == nullptr) {
21102180
mr->dhrs = not_found_page(mr);
21112181
}
21122182

21132183
raw_response = get_raw_response_with_fallback(mr);
21142184
if (raw_response == nullptr) {
2115-
mr->dhrs = internal_error_page(mr, true);
2185+
// Belt-and-suspenders: even get_raw_response_with_fallback's
2186+
// own try/catch couldn't produce a response. Force the empty-body
2187+
// 500 fallback so MHD always has something to queue.
2188+
mr->dhrs = internal_error_page(mr, "", /*force_our=*/true);
21162189
raw_response = materialize_response(mr->dhrs.get());
21172190
}
21182191
decorate_mhd_response(raw_response, *mr->dhrs);

0 commit comments

Comments
 (0)