What kind of errors can or should be handled by the user facing code?

So, lately I have been thinking about API design and this is one of things I can’t really put my finger on it. For the sake of clarity, when I talk about “error” in this post I mean zig errors.

Lets say (a hypothetical scenario) in the API there is a function called veryImportantFunction and it can return variety of errors:

const VeryImportantFunctionError = error{
    OutOfMemory (ofc),
    InvalidParameter,
    LostSomethingImportantWhileTheFunctionWasExecuting,
    SomethingWentWrongWithTheBackendAndWeDontKnowWhy,
    MaliciousData,
    (maybe more)
};

Now obviously these error might look a bit different in real code, but here is the problem: Can the user of this API handle every error and should be handling every error?

Here are my thoughts and questions>

These are the errors I think should be handled by the user:

  • OutOfMemory can be handled in different ways, user might have passed a fixed buffer allocator or something and it just ran out of memory and user can do something about it. Or OS is going to kill your application, so get ready.
  • MaliciousData great, please do something about it, idk reject the given data?
  • LostSomethingImportantWhileTheFunctionWasExecuting ok cancel the operation or try to get that important thing and retry.

Now these ones are a bit different:

  • InvalidParameter: So user provided an invalid parameter to the function, but why? Was it just a simple error, a typo? Or a result of some variable being invalid? Either way is this even recoverable? Should the user just move on and act as it was successful or just revert to some valid state? Obviously answer to all of these depends on the application and the problem we are facing, but for the argumant sake, lets say this error has nothing to do with the application user and everything to do with the API user. Should this error really be an error instead of a panic/crash? I think that this would come up in testing and be fixed before actually shipping the application. So why have it as an error?
  • SomethingWentWrongWithTheBackendAndWeDontKnowWhy: This one is a different kind of hell, because the API should handle its own backend errors and maybe return an error that can be operated on to the user. But what if the API dont know why the error happened in the first place? Should it just crash and burn, continue to operate, or return an error to the user? In cases where it really would not matter if an error occured and application can continue to work, should it still return an error? In another case where the error is fatal, what should the API do? If the function returns an error, what can the user of this API even do in these situations?

As the API developer you really don’t know where the caller got the parameter from or if it’s something they can handle or not, sure many cases may rightfully be a crash caught in testing, but you don’t know that is my point. Give the caller the choice.

That being said, for internal APIs you do know the caller and can rightfully make assumptions and decisions based on that.

Again, best to give the caller control, they have context and application knowledge the API doesn’t. Even literally every case can’t recover/continue, it’s the callers responsibility to log, report errors to user, gracefully exit or something else.

1 Like

You are right about giving caller the choice but I feel like there is an argument to be made to the other side of the coin. Giving caller choice is what I always did, for the last 5-6 years, but lately I have been exploring the idea where the code just keeps running without interrupting the flow.

mything = createMyThing() // returns an invalid handle because it failed at some point.
mything.doTheThing(); // basically does nothing because the handle is invalid.

This is obviously not for every type of program out there, but I personally think sometimes just chugging along would result in better (application user and developer) experience. Giving an error to the user here may not be the optimal solution as the default case. Sometimes I just want to do some prototyping without worrying about errors, or I am fine with some parts of my program not working properly but I want it to keep running and I don’t want to write special code path when it failed (so it can keep running).

Maybe providing both options would be a better choice. What do you think?
As for the default case being returning an error or make it “chug along”, that debate will never end probably.

For the crashing case, I think you are right giving caller the choice if it is not internal use, where we know the intention. But I think API should take some responsibility as well and give some sort of description on what happened and where. Meaning it should at least log it by DEFAULT. I have been bit by this a lot and I always think why they dont default to logging errors and they instead having the user to enable it in some sort of way. Well that is if they even have a way to log errors of course.

1 Like

Maybe providing both options would be a better choice

I think I should say this as well, the reason for this is that in zig, as we all know, you cant return an error and a value. At least without going through some hoops. Which worsens the user experience. I know Zig favors reading code over writing it but I feel sometimes this does not produce any advantages.

2 Likes

As noted, there isn’t a one-size-fits-all rule here, it does require judgement.

When an error occurs inside the function, I ask:

  • who’s at fault for the error?

  • can I recover from this error?

  • is the recovery path reasonably aligned with my responsibilities as a function? Would it require me to do something surprising?

  • would the return value in case of recovery actually give the caller what they need, or just lead to further errors?

Trying to handle and suppress errors which are not in the scope of a function’s responsibilities is usually a bad idea, and it’s better to allow the error to propagate up the stack in the hope that it does find a function whose scope is appropriate to own a recovery path.

2 Likes

Zigs errors are for control flow and nothing else, there is a balance of what are good and bad errors to return, which changes from other design decisions.

I didn’t mean to imply that you should return every error, to maximise what the caller can do. I think the opposite actually, errors have a limited scope where it’s reasonable to handle them, which leads to minimising errors at API boundaries.

An example would be the parser for a POC language I am making, there is a very small set of errors that are only used internally up to a point of recovery. It also utilises invalid, and error, nodes in the AST

3 Likes

I guess the point I am trying to make and argue for is that some errors are litterally cannot be recovered. Not all APIs are general purpose and single purpose APIs generally know the target audiance needs. So they may know the path of crash/continue silently even if it actually failed. Having a way to return both an error and a value as to indicate “it kinda failed but you can still go on to do your thing” kinda thing would be nice. Though the problem in the question I am asking seems to be coming from somewhere else*.

Yeah I guess thats mostly where I am right now. I have been using zig since 0.7.1 and very familiar with its design at this point. I have never had a real need for a tagged union style of errors until like I was designing a real API I want to rely on for a long time. So now I understand why people want that kind of thing. Though I may have to say this, Zigs approach to errors is pretty good too.

Yeah I agree with this.

*Ultimately the actual problem seems to be minimizing the errors and their paths so the user facing code does not have to deal with ton of errors. I always try to get rid of one more error from my error sets but sometimes I feel like problem might be the semantics of the API itself, and that leads me down into different styles of error handling.

2 Likes

Whether to use errors to express control flow mainly depends on errdefer. If during your execution there’s an issue in your eyes, but this issue doesn’t interrupt the execution flow, meaning you don’t need to release resources early because of it, then you don’t need to use an error to represent this problem.

1 Like

My rule of thumb is basically:

  • Obvious and simple API misuse (e.g. the InvalidParameter case) is (usually) a panic checked via asserts, not an error result (of course here Zig’s controversial decision that asserts turn into undefined behaviour in release-fast mode needs to be taken into account, e.g. you probably want a separate assert which also panics in release-fast mode and a panic handler which reports back to the developer via an error tracking solution like Sentry). Also, Zig already prevents many of such errors by design, e.g. it’s impossible to pass a null-pointer into a function that only expects non-null-pointers, and range-checks panic at least in debug- and release-safe mode - but of course you code should already catch a range validation before it actually happens…
  • Any malformatted runtime input is an error result (e.g. file content that doesn’t pass validation, or free-form user input coming from an UI).
  • And finally: comprehensive error checking and input validation happens only at the public API boundary, that way internal functions don’t need to do redundant checks (sometimes it’s useful to have such “defensive” can’t-happen checks though).

There’s a grey zone between requiring the API user to do validation upfront. For instance when an UI input value is coming exclusively from a restricted slider widget (e.g. it’s impossible to generate invalid values through the UI), then theoretically the API which consumes that values doesn’t need a strict validation. Or when your cmdline parsing library already has strict type checking and rejects invalid inputs you can relax the checks at the place where those values are consumed. But those things are basically moving the ‘public-vs-internal’ boundary to a different place.

PS: for libraries I would usually move the needle more towards the ‘error result’ instead of ‘panic’ though. Basically the lower-level and flexible a library is, the more it should delegate error handling to the caller. The caller can then still simply panic on some error types (like InvalidParameter). Sometimes I also start with a hard panic and then decide to ‘soften’ the panic into an error later (but almost never the other way around).

3 Likes