What is the idiomatic way to include additional information with errors?

Hach, I don’t know… this seems all like creating an entirely parallel error handling system because the builtin system isn’t sufficient, and a system that is less clear than the builtin error system.

Maybe I didn’t get some things from glancing over the code, but there’s a separate error stack which is separate from the call stack, so if an error is pushed on the error stack it looses the valuable call stack information (an error alone isn’t all that useful, it’s much more important where exactly the error happened and the callstack leading to the error - which of course is information the current error handling system also doesn’t provide and would probably be much too heavy to attach to each error - so scratch that…)

Pushing an error on this separate error stack doesn’t automatically imply that a function execution stops and returns an error - at most this happens by convention - but then why have an error stack?

Getting a whole list of errors when calling a complex function also isn’t all that useful, it might be that followup errors only happen because of a previous error. An error should always immediately stop execution of a function and return to the caller, but in that case, why even have an error stack? There would always only be a single error on the stack.

The Diagnostic pattern looks more like a solution for logging warnings instead of an actual error handling system.

Compared to that, an optional error payload that is always directly associated with the error itself seems like the much more straightforward solution which leaves less room for confusion.

5 Likes

This is because the information an error needs to carry can be more complex than you might imagine. I understand the importance of the error stack, but when you need to carry additional information, it’s clear that you believe the error stack isn’t sufficient. Similarly, different levels of the error stack will likely carry different additional information, and a single error message structure alone won’t suffice.

1 Like

I guess the main problem I have is that the Diagnostics object allows to decouple the error from its cause both in ‘time and space’ :wink:

Don’t take me wrong, I have used similar patterns many times in C code, but was never satisfied with it. Rust’s error unions were an eye opener (even though I don’t like the rest of Rust much).

I can immediately think of more than a handful cases where attaching a payload to an error return value would be the better solution than the various workarounds I did in the past.

Also tbh no matter how much I think about it, I don’t understand the “separating diagnostics from control flow” argument. What is the difference of returning an error union to returning an optional? If I want to use the payload of an optional I also need to unwrap it first. What is the ‘control-flow-difference’ of unwrapping an error union payload to unwrapping an optional payload?

I understand your need, as my practice also attempts to address this need. However, due to the intentionally limited metaprogramming capabilities of the error type, it fails to achieve maximum runtime efficiency.
I also understand Andrew’s point: the memory allocation process during the construction of the error structure itself can introduce new runtime errors and UAF, which is indeed very error-prone. Zig isn’t a language like Rust that guarantees memory safety, so while Rust can provide memory safety, Zig might cause more unexpected problems by providing this functionality.
My approach is that if a feature is prone to error, syntactic sugar can be omitted, but correspondingly, zero-runtime-cost metaprogramming can be provided to make it feasible. My current frustration with error is that its metaprogramming capabilities are intentionally limited, preventing it from achieving its full potential with zero runtime cost.

1 Like

Are we so sure it isn’t a technical limitation? My intuition, likely flawed:

  1. Part of the power of the existing error handling system is that it can infer the tightest possible error union, in principle any subset of the global error set. If it couldn’t do that you’d basically be back to roll-your-own tagged union-based result types.
  2. This automatic inference seems risky if not downright infeasible if you want to bring payloads into the mix. When all you have is a subset of the global error set, you can just represent it as a bitmask, totally obvious and uniform. You wouldn’t even have to intern it, you could just attach it directly to the function. If you want payloads, all of a sudden you have to actually compute a layout for this thing, intern it, etc, it could easily end up exploding combinatorially. Better to let the programmer be responsible for it.
4 Likes

Diagnostics aren’t supposed to handle the error, rather they are a potential result of handling an error.

I agree it would be nice to have error payloads, but I would rather have friction to dissuade from providing context when it’s usually better to handle the error where the context is in my experience. I’ve been there with rust and I didn’t like it.

This discussion is mostly subjective, in opinion on how necessary context propagation is, and opinion on whether the current solutions are ‘bad’

4 Likes

For most apps I think rich logging is a far simpler approach over creating an error stack or tree.

Context for interpreting an error is simply looking up in the log to find relevant information. For example, instead of pushing the filename into an info context for creating error messages and passing it down, you just log "Opening file ‘foo’, then 20 levels deep in your recursive-descent parser, log "Error at [line:col]'Missing ‘]’.

This does put more of a burden on the user to analyze the context presented in the log, and there is more noise. it has a great benefit in that you don’t have to design an elaborate error context system. Just log important information as you go.

1 Like

Now that I am playing with it:
Is it possible to retrieve the error type when doing this?
We can get @errorName() but not the original error “enum” it seems.

run() catch |err| {}

I think the reason for this is that all errors are accumulated into a single global error set by the compiler (e.g. each ‘error enum item’ gets assigned an individual error code and the connection to its original ‘error set type’ is lost):

(key phrase: ‘But you cannot coerce an error from a superset to a subset’)

At the point where you’re catching an error, it has been ‘dissolved’ into the global error set, e.g. when you look at a whole call stack where each function passes errors received from a called function upwards, the individual error codes received in ‘upstream’ may have originated in various error sets. Maybe there’s a way to preserve the original ‘local’ error set, but then you’d also run into problems if two separate error sets define the same error ‘name’.

I suspected so. I know the errors are somehow internally merged.
Too bad. Now we only have the name.

the zig compiler (at least the parser) stores error info in a separate list. even if functions were able to return values with their errors (like in rust), it would still have to do this to allow for multiple errors in a file:

src

For me there are recoverable and non-recoverable errors.

For non-recoverable errors - allocation, io, etc, - I just want to log them and stop. I require memory and working disk to continue. So it works to pass down a logger of some sort. They can be logged as soon as they occur, and no context info needs to be communicated upward. The Zig error percolates upward and is used as the signal to stop.

For recoverable errors - that are handled at some level of the call chain and we continue - the error context pointer param, plus an associated Zig error code, works. The context info is needed where the error is handled, so declaring the context variable at that level makes sense, and intermediate levels just pass the pointer along. The Zig error is not allowed to percolate upward past the level where it is handled; this can be enforced by not including it in the error switch statements in the levels above that.

std.log is a global logging api that lets you provide a custom implementation via std.options

1 Like

A library I use takes a simple approach to this. If a function creates an error, a related object is passed the error before the function returns. You can then access the arbitrary data there

Good point, I see it works for libraries as well.

For simple applications i had success with passing down a *std.Io.Writer and at the location where error happens before error is returned to the caller writing the user friendly error message into the wirter.

The toplevel code then inspects the error code and if it is a certain type, it knows the error message was writter so it just calls flush and handles the error.

That way the builtin errors are used only for control flow.

I also don’t pretend that I need to return an allocated string to the caller only for them to write it to stderr anyway :^)

Note that this is of course only suitable for applications, not libraries.

A great helper function for this pattern is this:

fn invalid(w: *std.Io.Writer, comptime fmt: []const u8, args: anytype) error{ WriteFailed, InvalidCommand } {
    try w.print(fmt, args);
    return error.InvalidCommand;
}

Code with this pattern: https://git.sr.ht/~prokop/uzenatch/tree/2111c4c038cf3a69ea536b669e7e70dcc90476f1/item/src/uci.zig#L400

std.log provides a global logging api that applications can override the implementation of for their own needs.

1 Like

Zig’s error tracing is quite impressive. It’s proven in practice that, during error handling, as long as an error is ultimately returned, it will record all errors that occurred within the error handling catch block, regardless of errors being pushed onto the stack during error handling.

This test provides a simple demonstration:

test "debugErrorStack" {
    const S = struct {
        fn returnError() !void {
            returnErrorInner() catch |err| {
                _ = @errorName(err);
                return error.SomeError;
            };
        }
        fn returnErrorInner() !void {
            return error.InnerError;
        }
        fn logError() void {
            if (@errorReturnTrace()) |trace| {
                std.debug.dumpStackTrace(trace.*);
            }
        }
    };
    S.returnError() catch |err| {
        _ = @errorName(err);
        S.logError();
    };
}

It will print:

D:\zig-patterns\test.zig:189:13: 0x7ff69682100e in returnErrorInner (test_zcu.obj)
            return error.InnerError;
            ^
D:\zig-patterns\test.zig:185:17: 0x7ff6968210ad in returnError (test_zcu.obj)
                return error.SomeError;
                ^

Even if the error is discarded inside the catch block and replaced by another error midway, the error trace will always be tracked. As long as these processes are always in the catch block, this record will be saved.

I think this is another argument that this diagnostic is superior to other diagnostic solutions that use enum to record errors. The diagnostic solution in the standard library breaks the error trace when converting the error to enum, while this solution can always keep the error trace.

2 Likes

Even if the error is converted to an enum, it should still be tracked as long as you return an error in a branch off the original. It doesn’t have to be in a catch, rather you can use any other control flow that accept errors to handle it.

1 Like

Indeed, my dissatisfaction with the existing diagnostic mechanism mainly comes from std.tar, because its implementation always requires checking diagnostic to confirm errors, instead of returning the errors that should be returned. Please forgive me for always focusing on the implementation of this diagnostic, as it is the only diagnostic implementation I have found in the standard library that is not used for text processing, while the diagnostic used for text processing generally still has certain differences from my needs.

However, if an additional new error return value is added to the existing implementation of std.tar, it becomes much more acceptable.