Error Handling

7 vues Markdown

One handler catches everything and decides what the caller sees. That is why you throw instead of building error responses, and why a silent failure is almost always a caught one.

Overview

Startup installs handlers for PHP errors, uncaught exceptions and fatal shutdowns. From that point nothing fails on its own terms. The handler records it, then shows it in the shape the request expects, an HTML page or a JSON body.

Inside an operation there is a second layer. The dispatcher wraps your method in a try block. An exception you throw becomes the error response, with your message as its text. That is the whole error path for a mutation: throw, and stop writing.

Reference

Raising a Failure

Operations Throw an exception whose message comes from the translation layer. The dispatcher turns it into the error response.
Modules Throw, exactly as an operation does. The caller catches it and surfaces the message. An older module that assigns to an $error property and returns false is carrying a pattern from the previous major version. The base classes still translate that into a throw, but new code does not write it.
Helpers Return a falsy value and let the caller decide, or throw when the caller is always an operation.

Recording

MioException, an instance through getInstance()
public static function getInstance(): MioException;
public function initialize(): void;

public function logError(string $message, array $context = [], string $level = Logger::LEVEL_ERROR, string $type = Logger::TYPE_SYSTEM): string;
public function logDatabaseError(string $query, string $error, array $params = []): string;
public function sanitizeData(array $data, int $depth = 0): array;   // $depth is internal recursion; pass nothing

public static function showErrors(): void;
Logger, mostly used through its statics
public static function getInstance(): Logger;
public function log(string $level, string $message, array $context = [], string $type = self::TYPE_SYSTEM): string;

// One static per level, all with the same shape.
public static function debug(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string;
public static function info(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string;
public static function warning(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string;
public static function error(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string;
public static function fatal(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string;

public static function database(string $query, string $error, array $params = []): string;

// Runs the callback, records anything it throws and returns null instead of rethrowing.
public static function safe(callable $callback, array $context = [], string $errorMessage = 'Operation failed');
MioException::getInstance() The handler. A singleton, installed during startup by initialize().
logError() Records a failure with context and returns the identifier, which is the same identifier the caller is shown.
logDatabaseError() The same for a query. The statement comes first and the message second, and the context it builds is ['query' => ..., 'parameters' => ...].
sanitizeData() Returns a copy with sensitive values replaced. Run request data through it before logging anything.
Logger::error() The everyday form, one static per level. Same result as the handler's method, without reaching for the singleton.
Logger::safe() For work that must not take the request down with it, such as a provider call made while building a page.
Logger::findById() The record behind an Error ID, or null. That identifier is what a support request arrives with. It takes you from the customer's message to the stored context.

Levels and Types

Both are plain strings, and the constants are the only spelling that is safe to write. Every recording call returns the identifier as a string. It is uppercase hexadecimal padded to at least six characters, followed by three digits, so 4F59C9BC644 rather than a fixed width. Store it as text and do not validate its length.

ConstantValueUse it for
Logger::LEVEL_DEBUGDEBUGDevelopment tracing, not for a shipped path.
Logger::LEVEL_INFOINFOSomething happened that someone may want to confirm later.
Logger::LEVEL_WARNINGWARNINGRecovered, degraded, retried.
Logger::LEVEL_ERRORERRORThe default. The operation did not do what it was asked.
Logger::LEVEL_FATALFATALThe request cannot continue. Written by the shutdown handler.
Logger::LEVEL_DEPRECATEDDEPRECATEDEngine deprecation notices, kept apart so they can be filtered out.
Logger::TYPE_SYSTEMsystemThe default type, everything that is not a query.
Logger::TYPE_DATABASEdatabaseQuery failures. Kept in a separate log so one noisy query cannot bury the rest.

What Redaction Actually Matches

A key is redacted when its lowercased name contains any of password, pass, passwd, pwd, secret, token, key, auth, api_key or private_key. It is a substring test, applied recursively to nested arrays, and it looks at keys only.

in, and out
$in = [
    'email'      => '[email protected]',
    'Password'   => 's3cr3t',            // matched case insensitively
    'api_token'  => 'abc123',            // contains "token"
    'keyword'    => 'hosting',           // contains "key" - redacted too
    'server'     => ['auth_user' => 'root', 'port' => 22],
];

$out = MioException::getInstance()->sanitizeData($in);

// [
//     'email'     => '[email protected]',
//     'Password'  => '***REDACTED***',
//     'api_token' => '***REDACTED***',
//     'keyword'   => '***REDACTED***',
//     'server'    => ['auth_user' => '***REDACTED***', 'port' => 22],
// ]

What the Caller Sees

SituationResponse
Exception thrown in an operation{"status":"error","message":"<your message>"}
Error caught by the handler during an AJAX request{"status":"error","message":"Error ID: <id>: <text>","error_id":"<id>"}, plus a debug member when diagnostics are on
Fatal during a pageThe error page; detail only when diagnostics are on
Non-fatal warningRecorded; visible on the page only when diagnostics are on
Anything on the command lineRecorded; the message can be swallowed by the output buffer

Example

failing in the two places you will fail
// In an operation: throw, and let the dispatcher answer.
public function delete_widget(Operation $operation): bool
{
    $operation->demo();

    $id = (int) Filter::init("POST/id", "rnumbers");
    if (!$id) throw new Exception(Language::gc("widgets/error-id-required"));

    if (!$this->model->delete($id)) throw new Exception(Language::gc("widgets/error-delete-failed"));

    return $operation->output(['status' => "successful"]);
}

// In a module: throw. The caller catches it and surfaces the message.
public function create(): array|false
{
    $response = $this->api->create($params);

    if (!($response['success'] ?? false))
        throw new Exception((string) ($response['message'] ?? $this->lang["err-provider"]));

    return ['status' => 'SUCCESS'];
}
the other side: recording, and what the browser receives
// Recording something you handled yourself. Redact first; the identifier that
// comes back is the one to quote in the message the operator will read.
$mio = MioException::getInstance();
$id  = $mio->logError("Provider refused the transfer", $mio->sanitizeData($_POST), Logger::LEVEL_WARNING);

throw new Exception(Language::gc("services/error-transfer", ['{id}' => $id]));

// The browser then receives, from the operation dispatcher:
//   {"status":"error","message":"Transfer failed. Reference: 4F59C9BC644"}
//
// Reading it back on the command line:
//   php coremio/errlog.php list --limit=10
//   php coremio/errlog.php show <signature>

Pitfalls

Three neighbouring calls, three different first arguments

logError() starts with the message, logDatabaseError() starts with the query and takes the message second, and Logger::log() starts with the level. Swapping the first two in the database call is silent. You get a log entry whose message is a SQL statement, and whose recorded query is an error text.

Every warning is a disk write

The handler records non-fatal warnings too, and each record is a write. A missing array key read inside a loop costs one write per iteration. That is why values are read with a default rather than hopefully.

Redact before you log, and read the match rule first

The log is a file that outlives the request and gets copied around. Redaction is a substring test on key names, so it is generous in one direction and blind in the other. A field named card or answer passes through untouched. Do not pass raw request data and assume the list covers your field.

A quiet failure is still recorded

Nothing on screen means the handler decided not to show it, not that nothing happened. Read the log before concluding that a code path was never reached.

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Besoin d'aide supplémentaire ?

Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.