Every docs URL answers with Markdown when you add .md. Site map: llms.txt
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.
Bu dokümanlar şimdilik İngilizce.
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:
-- 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:
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')
...
endRemember 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:
-- 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))
...
endThe 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.
---@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:
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, ', ')))
endNine 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:
local ok, result = pcall(json.decode, body)
if not ok then
print(('[fm_api] bad JSON from %s: %s'):format(url, result))
return
endUse 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.
-- 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.
