Debugging

Lua Error Handling: Recover Without Hiding the Problem

Use explicit validation, protected calls, and useful failure contracts to keep recoverable errors from becoming silent corruption.

A magnifying glass inspecting a cracked cube beside tools and a repaired cube
Original tutorial artwork · PixelDevHub
PixelDevHub editorial desk · Intermediate · 5 min read · Core Lua 5.4

Separate expected rejection from unexpected failure

Not every unsuccessful operation should crash the current task. A player lacking enough crafting ingredients is an expected outcome that your interface can explain. A storage adapter returning a malformed result may indicate a broken dependency. Give these cases different contracts. A normal rejection can return nil and a useful explanation. An unexpected failure can raise an error or return a structured failure to the operation's owner. The owner then decides whether to retry, show a message, or stop processing.

If every failure becomes a generic false, you lose the context needed to fix the system. If every rejected input raises an uncaught error, ordinary user behavior becomes noisy. Write down which outcomes your caller should handle as routine control flow before adding broad error wrappers around the implementation.

Validate outside data at the boundary

Treat a successful transport or file read as the start of validation, not the end. A configuration can load successfully and still contain a missing field, invalid type, or unreasonable value. Validate the resulting structure before assigning it to active state. In a game host, the same principle applies to client requests: an error handler does not establish permission, ownership, or trust. Those checks must be part of the operation's contract.

Use pcall around a defined failure boundary

Wrap the operation that may fail, inspect its status, and preserve a meaningful response for the caller. Keep the protected area small enough that you know what a failure means. The following example accepts a loading function, handles its failure, and validates the returned configuration. It replaces unknown error details with a caller-facing message. A real integration should also record appropriately sanitized diagnostic context where the developer can inspect it.

local function readSettings(load)
    local ok, settings = pcall(load)
    if not ok then
        return nil, "Settings could not be loaded"
    end
    if type(settings) ~= "table" then
        return nil, "Settings must be a table"
    end
    local limit = settings.maxPlayers
    if type(limit) ~= "number" or limit ~= limit
        or limit < 1 or limit > 100 or limit % 1 ~= 0 then
        return nil, "Invalid player limit"
    end
    return { maxPlayers = limit }
end

local settings = assert(readSettings(function()
    return { maxPlayers = 12 }
end))
assert(settings.maxPlayers == 12)

local failed, message = readSettings(function()
    error("Example loading failure")
end)
assert(failed == nil)
assert(message == "Settings could not be loaded")

Do not confuse catching with rollback

A protected call changes how an error returns to its caller. It does not reverse writes that already happened. If a function subtracts currency, grants an item, and then fails while saving, catching the error does not restore a consistent transaction automatically. Plan the state transition deliberately. Keep validation before mutation, use a supported transactional mechanism where available, and design retries around unique operation identifiers. A catch-and-retry loop without those safeguards can duplicate a side effect.

Make retries bounded and observable

Only retry failures that might reasonably be temporary. A missing permission or invalid schema is unlikely to improve after a short delay. Give retryable operations a maximum attempt count, a delay policy appropriate to the host, and a final failure path. Do not build a tight repeat loop around an unavailable service. Keep records of the operation identifier and attempt number without logging secrets or excessive personal information. Bounded recovery is easier to reason about than an invisible background loop that never stops.

Exercise: test the failure path first

Provide the loader with a function that throws, one that returns a string, one that returns an invalid limit, and one that returns a valid table. Assert that failed calls do not replace your current working settings. Then add a caller that displays a short recovery message and records a diagnostic category. Notice that the handler itself can fail if it assumes an error is always a string or a service is always available. Keep recovery code simple enough to trust when the normal path is already broken.

Language reference

These examples use core Lua rather than an engine API. Consult the official Lua 5.4 reference manual for the language contract. Embedded runtimes can differ in available libraries, loaders, and scheduling behavior.

← Explore the Lua library

Give error messages an audience

A developer diagnostic and a player-facing recovery message have different jobs. The diagnostic should identify the failing operation and enough non-sensitive context to investigate it. The player-facing message should explain what they can do next without exposing internal paths or private data. Keep those two channels separate rather than returning a raw error object to every caller. Error objects are not guaranteed to have a useful display format. Treat formatting as part of the recovery path and keep it simple. A handler that performs several additional fragile operations can fail while trying to describe the original problem, leaving you with less information than you started with.

Define success before introducing a retry

For an operation with side effects, decide how the caller can determine whether it already completed. An interrupted response does not necessarily mean that the underlying action failed. If a grant or payment-like operation supports a unique request identifier, preserve that identifier across attempts rather than creating a fresh one each time. This tutorial does not supply a transactional storage layer, but the design question still matters in a small prototype. A protected call only reports how execution returned. It does not certify that nothing changed. Document the difference between a rejected request, an uncertain outcome, and a confirmed completed operation before automating another attempt.

Make failure tests describe state as well as messages

A useful failure test checks both the returned explanation and the data that remains afterward. Begin with a known valid configuration, attempt to load an invalid replacement, and confirm that the previous configuration is still active. For a multi-step update, inject a failure at each boundary where external work can fail. Observe which local changes have already occurred and whether cleanup is reliable. Keep those test cases close to the operation's contract so future changes do not quietly weaken recovery. The objective is not to wrap every function in a catch mechanism. It is to make expected recovery behavior visible where failure can otherwise leave the program in an ambiguous state.

Find your next tool or idea

Search tools, guides, and Lua resources.

↑ ↓ Browse resultsESC to close

Choose a tool