Hierarchical error handling
This PR is part suggestion, part implementation. If you can think of an easier to do what I'm trying to do, I'm all ears.
I have an application that has a lot of duplication in terms of issuing diagnostics for error handling, and I want to keep the error handling localized as much as possible. It has an intrinsic hierarchy that mirrors the error-generating code, but I can't represent that hierarchy well with just a flat sequence of handlers. Shortened example:
leaf::try_catch(
[] { return open_project (); },
[](e_project_path path, e_missing_file missing) {
log("Error while opening project in {}", path.value);
log("Missing file: {}", missing.value);
},
[](e_project_path path, e_json_string json_str, e_json_parse_error json_err) {
log("Error while opening project in {}", path.value);
log("Error in JSON '{}'", json_str.value);
log("Invalid JSON: {}", json_err.value);
},
[](e_project_path path, e_json_string json_str, e_json_schema_error err) {
log("Error while opening project in {}", path.value);
log("Error in JSON '{}'", json_str.value);
log("JSON data is invalid: {}", err.value);
});
In reality there can be dozens of handlers that have some amount of duplication between them, and it can become difficult to browse and update as error conditions are added.
This PR adds leaf::handle_more that can be used to do nested error handling. It looks like this:
leaf::try_catch(
[] { return open_project (); },
[](e_project_path path) {
log("Error while opening project in {}", path.value);
return leaf::handle_more(
[](e_missing_file missing) {
log("Missing file: {}", missing.value);
},
[](e_json_string json_str) {
log("Error in JSON '{}'", json_str.value);
return leaf::handle_more(
[](e_json_parse_error err)
{ log("Invalid JSON: {}", err.value); },
[](e_json_schema_error err)
{ log("JSON data is invalid: {}", err.value); });
});
});
Here are the semantics:
- The return type of
handle_moreis an opaque type that captures the handler types and a "return type" which can be provided explicitly as the sole template argument or will be deduced as the common type of the return types of the handlers (If another nested handler returns anotherhandle_more, the common type will "unwrap" thehandle_more-return-type for that handler). - The
contextdeduction helpers recognize if a handler returnshandle_more, and will recurse into its nested handlers to find additional types that it needs to create slots for. - The nested handler is invoked directly within the
handle_moreevaluation. - The return values from
handle_morewill propagate out: So thehandle_morehandlers need to have a return value that is convertible to the overall return type of thetry_handle_{some,all}.
Here's the implementation details:
- The return type of
handle_moreisleaf_detail::more_handlers_result<R, H...>, whereRis the common result type andH...are the inner handlers. - The machinery of
handle_moreis inhandler_caller, which will recognize a handler that returns amore_handlers_result. - The best way I can initially think to get the slots tuple down into
handle_morewas by using a thread-local pointer to an abstract class with avirtual R invoke(H&&...)(more_handler_invoker_base<R, H...>). Thehandler_callercreates a concrete instance that stores the slots anderror_info, and then stores the thread-local pointer-to-base for that invoker.handle_morethen loads that pointer and callsinvoke(hs...), which then does the actualhandle_error_(tup, ei, hs...). This trickery with thread-local pointers feels a bit hairy, though...? - The
handler_callerwill return the actual return value of the handler by unwrapping it from themore_handlers_resultobject.
Open issues with the current implementation:
- In the above example, the handler for
e_project_pathwill be invoked if that slot is loaded, but it is possible that none of the nested handlers would be satisfied. Currentlyhandle_morerequires an "everything" handler at the end (like withtry_handle_all), but it would be better if it recognized whether it was being called fortry_handle_allortry_handle_somebefore requiring a catch-all handler or not. - The thread-local pointer is loaded before the handler containing the
handle_moreis invoked, and only loaded whenhandle_moreis invoked later on. In a pathological case, some code in between those two points could change the pointer and this will explode inhandle_more:slightly_smiling_face:.
This is interesting, but maybe I'm missing part of the motivation for it. Would this approach not be sufficient for your use case:
leaf::try_catch(
[] { return open_project (); },
[](e_project_path path, e_missing_file const * missing, e_json_string const * json_str, e_json_parse_error const * err_parse, e_json_schema_error const * err_schema) {
log("Error while opening project in {}", path.value);
if( missing )
log("Missing file: {}", missing->value);
if( json_str ) {
log("Error in JSON '{}'", json_str->value);
if( err_parse )
log("Invalid JSON: {}", err_parse->value);
if( err_schema )
log("JSON data is invalid: {}", err_schema->value)
}
});
Sorry for the delay. Got busy just after I pushed this.
In general, yes, using pointer parameters allows similar behavior for many cases, but the same could be said for the utility of having multiple handlers at the top level. It is effectively putting the responsibility of handler dispatch back on the user, which defeats some of the declarative nature of the API.
I just pushed another set of changes that fixes Issue [1] in my prior comment. That is: Now when dispatching to a handler, the check for handler viability now additionally consults the return type of the handler: One of the handlers in a more_handlers_result<> must be viable for the current error, otherwise the enclosing handler is not viable. This applies recursively.
This changes things up a bit, since now pointer parameters can no longer emulate the feature:
int r = leaf::try_handle_all(
[]() -> leaf::result<int>
{
return leaf::new_error(info<1>{}, info<3>{});
},
[](info<1>) { // [h1]
return leaf::handle_more([](info<2>) { return 2; });
},
[](info<1>) { // [h2]
return leaf::handle_more([](info<3>) { return 3; });
},
[] { return -1; }
);
With this fix, only handler [h2] will be viable with info<1> and info<3> loaded, as [h1] requires info<2> due to the handle_more return value.
I'll review tomorrow, but I have questions:
- What happens if we make this change:
int r = leaf::try_handle_all(
[]() -> leaf::result<int>
{
return leaf::new_error(info<1>{}, info<3>{});
},
[](info<1>) { // [h1]
return leaf::handle_more(
[](info<2>) { return 2; },
[] { return 3; }
);
},
[](info<1>) { // [h2]
return leaf::handle_more([](info<3>) { return 3; });
},
[] { return -1; }
);
Presumably h2 will never be considered, since h1 handles all errors?
- Does all this work correctly when handlers are captured with
std::make_tuple? In other words, would this work as expected:
auto handlers = std::make_tuple(
[](info<1>) { // [h1]
return leaf::handle_more([](info<2>) { return 2; });
},
[](info<1>) { // [h2]
return leaf::handle_more([](info<3>) { return 3; });
},
[] { return -1; }
);
and then:
int r = leaf::try_handle_all(
[]() -> leaf::result<int>
{
return leaf::new_error(info<1>{}, info<3>{});
},
handlers );
);
-
Yes, it will call
h1because there is ahandle_morehandler that handles all cases after theinfo<1>is handled. -
It should, but I'll add some test cases to make sure.
I'm concerned about making it even more difficult than it already is to know what handler will be called when. Without handle_more, at least we know we have to consider one level of handlers in the order they're listed, like we're used to do with catch statements. I have to think. It'd be more convincing if I found the pointer argument version I posted in any way inferior, but I don't think that it is: either way you have to read more code, and the pointer version has the benefit of that code being just plain C++, rather than template magic. For example, you can set a break point and step through to see what happens.
Why don't you like the pointer argument version?