Przejdź do treści
Error handling

Error handling

Assertions for bugs, returned errors for expected failures, checking preconditions up front, using pcall honestly and writing messages you can actually debug from.

Ta dokumentacja jest na razie po angielsku.

Last updated

A silent failure is the most expensive bug there is. It does not show up in the console, nobody reports it for a week, and when they do the report is “the shop is weird sometimes”. Deciding up front how each kind of failure behaves is most of what error handling means.

Two kinds of failure, two different answers:

Kind Example Answer
Bug A function was called with a table where it expects a number Throw. Loudly. Fix the caller.
Expected failure The player typed an amount they cannot afford Return a result the caller can act on.

Mixing these up is what produces either a console full of errors nobody reads, or a resource that quietly does nothing.

assert for preconditions

assert is shorter than the if it replaces and it puts the message next to the condition:

Lua
-- Noise
if type(amount) ~= 'number' then
    error('amount must be a number')
end

-- Same thing
assert(type(amount) == 'number', 'amount must be a number')

Write the assumptions of a function at the top of it, one per line. If you cannot name an assumption, you have found something you had not thought about yet:

Lua
local function transfer(fromSrc, toSrc, amount)
    assert(type(fromSrc) == 'number', 'fromSrc must be a player server id')
    assert(type(toSrc) == 'number', 'toSrc must be a player server id')
    assert(type(amount) == 'number' and amount > 0, 'amount must be a positive number')
    assert(fromSrc ~= toSrc, 'cannot transfer to yourself')
    ...
end

Remember that assert evaluates its message argument whether or not the assertion fails, so do not build a string in it inside a hot loop. assert(ok, 'bad id ' .. tostring(id)) concatenates on every successful call too.

Fail loudly when the state makes no sense

The tempting line is if not somethingWeird then return end. It makes the error go away and it makes the cause impossible to find:

Lua
-- Silently does nothing, forever
local function payout(src)
    if not IsPlayerDead(src) then return end
    ...
end

-- Says what went wrong, the first time it goes wrong
local function payout(src)
    assert(IsPlayerDead(src), ('payout called for living player %s'):format(src))
    ...
end

The difference matters when the condition should be impossible. If a dead player check is a normal part of the flow, a quiet return is correct. If reaching that line with a living player means somebody called the function wrong, throw.

The question to ask is: could a player cause this, or only a programmer? Player input is an expected failure. Programmer mistakes should be loud.

Return errors for things players do

Validation on a net event is not a bug. Players will send bad data, sometimes by accident and sometimes on purpose, and throwing an error every time floods your console and gives an attacker a way to fill your logs.

Lua
---@return boolean ok
---@return string? reason
local function buy(src, item, amount)
    local def = Items[item]
    if not def then return false, 'unknown_item' end
    if amount < 1 or amount > def.maxStack then return false, 'invalid_amount' end
    if getMoney(src) < def.price * amount then return false, 'insufficient_funds' end
    ...
    return true
end

RegisterNetEvent('fm_shops:server:boughtItem', function(item, amount)
    local src = source
    if type(item) ~= 'string' or type(amount) ~= 'number' then return end

    local ok, reason = buy(src, item, amount)
    if not ok then
        return TriggerClientEvent('fm_shops:client:purchaseFailed', src, reason)
    end
end)

The caller decides what to do, the player gets a message instead of a hang, and nothing lands in the console. Return a short machine readable reason code rather than a sentence, so the client can translate it and so your own code can branch on it.

Partial work: log, do not abort

When an operation covers several independent items, throwing on the first problem throws away the work that succeeded:

Lua
local failed, n = {}, 0
for i = 1, #items do
    local ok, reason = sellItem(src, items[i])
    if not ok then
        n = n + 1
        failed[n] = ('%s (%s)'):format(items[i], reason)
    end
end

if n > 0 then
    print(('[fm_shops] %s: %s of %s items failed: %s'):format(src, n, #items, table.concat(failed, ', ')))
end

Nine items sell, one is reported, and you have a line in the log naming the player and the item. That is the right shape whenever one failure does not invalidate the rest.

pcall honestly

pcall catches an error and hands you the message instead of letting it reach the console:

Lua
local ok, result = pcall(json.decode, body)
if not ok then
    print(('[fm_api] bad JSON from %s: %s'):format(url, result))
    return
end

Use it around things that can fail for reasons outside your code: parsing data you did not create, an HTTP response, a database call, a third party export that might not be loaded.

Warning

Do not wrap your own logic in pcall to make the red text stop. That is not error handling, it is hiding the only clue you had. If you catch, log what you caught.

Messages you can debug from

A good error message names the resource, the value and what was expected. A bad one says attempt to index a nil value.

Lua
-- Useless six months later
assert(shop)

-- Useful immediately
assert(shop, ('unknown shop id %s in config'):format(tostring(id)))

tostring() around the value keeps the message itself from erroring when the value is nil, which is the exact case you are reporting. Prefix console output with your resource name in brackets so the server console stays readable with 200 resources running.

For reading the errors you do produce, and for the tools that make them easier to find, see Debugging.