Search code examples
error-handlinglualua-5.1

Is using lua's error(.., level) an anti pattern?


Lua 5.1's API provides an error() function, which accepts a string (the error message) and a "level".

My understanding is that level, lets you move up the call stack, so you can provide nicer error reporting, especially when delivering a module as an API.

For example, imagine the user calls api_function(x) with x = nil. This will be an error, but the API doesn't know until it's quite a bit into it's code.

It may result in this call stack:

api_function(x)                     : user_file.lua:30
  -> api_function                   : api.lua:20
    -> some_function                : api.lua:250
      -> handle_when_x_string       : api_string.lua:20
        -> error("value is nil")    : api_string.lua:66

As written, the user will see something like api_string.lua:66 error: value is nil, when what they really want to see the "nice" error, user_file.lua:30 error: value is nil. ("Is that error my fault or a bug in the API?")

Now, we can change the code to "pop the call stack",

api_function(x)                     : user_file.lua:30
  -> api_function                   : api.lua:20
    -> some_function                : api.lua:250
      -> handle_when_x_string       : api_string.lua:20
        -> error("value is nil", 5) : api_string.lua:66

Which will return the "nice" error, but, imagine you can also call handle_when_x_string more directly (poor API design aside),

another_api_fn(x)                     : user_file.lua:44
  -> another_api_fn                   : api.lua:11
    -> handle_when_x_string           : api_string.lua:20
      -> error("value is nil", 5)     : api_string.lua:66

Now our "pop level" is incorrect. Perhaps in this example, it would simply pop to the top and stop trying, but the principle of "incorrect level" remains at least uncomfortable, it may even pop "out" of where the user caused the error.

I can see a few solutions:

  • Don't set level and just assume the user is smart enough to work it out.
  • Wrap anything below your api entry points (api_function & another_api_fn) in a pcall, catch any error and re-bubble with a known "good" level value.
  • Don't ever error in lower api functions, always return nil, error or some similar pattern, then check for that in api_function and act as required.

My questions are:

  • Is it a problem to return the wrong level? It seems poor form to just "yeah whatever" a number in there and hope it's good.
  • If it is a problem, when is it ever a good practice to set the level (beyond maybe 0 which disables location reporting)
  • Which of the solutions, if any, are best practice? What should I actually do to write better maintainable code? Wrapping in pcall seems the easiest, since you can still rely on "normal errors" when testing and your functions are somewhat simpler, but somehow it feels like an antipattern, in my head.

Solution

  • First, you need to differentiate from errors due to a bad API call, and actual bugs in your code.

    If the purpose of the error call is to tell the API user that they passed the wrong arguments, you should validate the arguments in every API function, so that the error level will be knowable, and so the rest of your library knows it's working with valid arguments. If you end up with a complicated hierarchy of validating functions, they can take parameters for the function name and error level. Here's a very contrived example for how you can use error levels:

    local function lessThan100(x, funcName, errorLevel)
      if x >=100 then
        error(funcName .. ' needs a number less than 100', errorLevel)
      end
    end
    
    local function numLessThan100(x, funcName, errorLevel)
      if type(x) ~= 'number' then
        error(funcName .. ' needs a number', errorLevel)
      end
      lessThan100(x, funcName, errorLevel + 1)
    end
    
    -- API function
    local function printNum(x)
      numLessThan100(x, 'printNum', 3)
      print(x)
    end
    

    If the error call represents a bug in your code, then don't use a level, because you can't know what triggers the bug.