What the user reads when an operation fails
Updated
When a save, a posting or a command fails, the user sees one sentence they can act on. They do not see a stack trace, an SQL message or a "Command execution failed:" prefix. The platform picks one user-facing sentence; scripts refuse with resources; technical detail goes elsewhere — the rules below.
Two kinds of failure#
| A refusal by a rule | A fault | |
|---|---|---|
| Example | "Not enough free stock of «Buckwheat 800g» in cell «DOCK-01-04»: 1 needed, 0 free" | a null reference in a script, a broken foreign key, a timeout |
| Who wrote the text | the script or the platform, for the user | the runtime or the database, for a developer |
| What the user sees | the text itself, as a warning | a translated sentence, as an error |
| Stack trace | none: nothing is broken | in the server log and in the bottom console |
| Server log level | Information or Warning | Error |
| HTTP status | 409 (or 200 with success: false for a command) |
500 |
A refusal is the normal answer of a business rule. The user has something to fix, such as stock, a date or a missing field. A fault means the system is broken, and the user can only report it.
How to refuse from a script#
Use one of these. All of them reach the user verbatim.
// In an event handler: the usual way.
return EventResult.Cancel($"Недостаточно товара «{itemName}» в ячейке «{cellName}»");
// From anywhere: a service, a command, a nested call.
throw new BusinessRuleException("Договор закрыт: выберите действующий договор.");
// Older scripts throw this. It still counts as a refusal (see below).
throw new InvalidOperationException("Налоговый код не найден");
Write the text for the person at the screen:
- Name what and where. "Not enough stock: 1 needed, 0 free" is useless on
a document with twenty lines. Name the item and the cell.
ICellFreeStock.ShortageAsyncdoes this for stock shortages; use it instead of formatting your own. - Say what to do when it is not obvious: "Add a rate in Exchange rates".
- Keep the fragments tests check. Integration tests match the "needed N, free M" part of the stock refusal, so keep it when you reword a message.
How the platform picks the sentence#
ErrorTranslator.Translate(exception) in src/ZuloOne.Runtime/UserErrors.cs
walks the exception and everything it wraps (inner exceptions, the members of an
AggregateException) and returns the first match:
-
BusinessRuleExceptionanywhere in the chain. Its message, unchanged. -
A plain
InvalidOperationExceptionthrown by our code. Its message, unchanged. "Our code" means the throwing method lives in a ZuloOne assembly or a compiled script, not in aSystem.*,Microsoft.*orNpgsqlassembly. The framework's own "Sequence contains no elements" is a fault, not a rule. -
A known technical failure. A fixed sentence for its kind:
Kind SQL Server PostgreSQL Sentence (gist) Duplicate key 2601, 2627 23505 a record with value «X» already exists Delete of a record in use 547 (DELETE) 23503 (delete) the record is referenced by other data Reference to a missing record 547 23503 pick the value again Required column empty 515 23502 fill in the field «Column» Text too long 2628, 8152 22001 shorten the text Number out of range 8115, 220, 232 22003 the number is too large Date out of range SqlDateTime overflow22008 fill in or fix the date Deadlock, timeout, aborted transaction 1205, -2 40P01, 57014 the server was busy, retry -
Anything else. A generic sentence: an internal error, with the details in the log.
The raw text of an unknown fault is never sent to the browser. It can contain a
connection string, a file path or a row of someone else's data. The log is
authenticated and the browser is not. The same rule applies to
GlobalExceptionHandler; the translator extends it to every write path.
Where the translator is applied#
| Place | What it does |
|---|---|
CommandExecutor |
A refusal returns Failed(message) with no errorDetails and logs at Information. A fault returns the translated sentence, and its stack goes to errorDetails. |
CommandsController, CommandFamiliesController |
An exception that escapes the executor is translated the same way. A refusal is a 200 with success: false, not a 500. |
EventExecutor |
A handler that throws produces the translated sentence, not "Event execution failed: …". A refusal keeps its text. |
DataService, DocumentPostingService |
An event cancel, a required-field check, a field validation and a closed-period refusal throw BusinessRuleException. A handler that crashed throws ScriptFaultException, with the handler's exception inside. |
DocumentsController, DataController |
The generic catch of create, update, delete, status and subtype returns the translated sentence instead of ex.Message. |
GlobalExceptionHandler |
A refusal is a 409 with its text, logged as a warning. Everything else is a 500 with the translated sentence. |
In the browser:
showCommandFailure(frontend/src/lib/commandFailure.ts) shows a command result withouterrorDetailsas a warning, and one with details as an error. The details go to the bottom console and never to the toast.errorText(frontend/src/ui/errorText.ts) drops adetailsfield that looks like a stack trace, and falls back to amessagefield. Record save paths use it instead of(e as Error).message, which is the axios status sentence ("Request failed with status code 409"), not the refusal.
Jump to the line that failed#
When a failure comes out of a script, the toast and the console line get a code button (tooltip «Open the code: ShipCommand.ExecuteAsync:19»). It opens the script in its editor tab at that line. If the tab is already open, it jumps there.
How the line is found:
ErrorTranslator.Locate(exception)walks the exception and everything it wraps, innermost first, and takes the first stack frame from a compiled script. Scripts are emitted in memory with an embedded PDB, so their assembly has no file, and that is how they are recognised. The generated halves (*.Generated.cs,Framework.cs,GlobalUsings.cs) are skipped. Async and lambda bodies are folded back to the method the author wrote. The result is the class, the method and the line of the STORED code.- If the executor turned a thrown handler into
EventResult.Error(text), the stack would be lost. The result keeps the exception, andScriptFaultException.For(reason, exception)rethrows it inside: as aBusinessRuleExceptionwhen the handler refused, and as aScriptFaultExceptionwhen it crashed. A crash in a handler is therefore not shown as a yellow refusal. ScriptCodeLocatorfinds the script by its class. Script class names are unique in a workspace, so the class identifies the script. The script id and name are filled in only for a caller allowed to read the «scripts» system registry. Everyone else gets no location: a class name and a line are a map of the code.
Where it travels: codeLocation on a command result, and location on the
error body of document and dictionary writes and of the global exception
handler. In the browser, lib/codeJump.ts opens the tab through the
client-action bus (OpenScript), so a toast outside the workspace can still
open a workspace tab.
A refusal by EventResult.Cancel has no stack. Its location is the script
line that started the operation, for example the command line that called
SaveDocumentAsync, not the handler that said no.
Traps#
A refusal that reads like a fault. When a script throws
NullReferenceExceptionbecause a field is empty, the user reads "internal error". Check the field and refuse with a sentence. The translator cannot guess what was missing.
Rewording a message breaks tests quietly. Integration tests match message fragments with
Contains. A test that checks the old wording fails only when it runs. SearchTests/for the old fragment before you rewrite a refusal.
InvalidOperationExceptionfrom a library is not a refusal. When your script calls.First()on an empty list, the user gets the generic sentence. That is intentional, because the text "Sequence contains no elements" helps nobody at the screen. UseFirstOrDefault()and refuse explicitly.
What's not there yet#
- Rules editable by an administrator. Translation rules are code in
ErrorTranslator. A dictionary of exception type, message pattern, priority and localized text is not in the product: a new database error kind needs a platform change. - Localized fault sentences. The translated sentences are Russian, like every other refusal the server writes. The user's interface language does not change them.
- A link from the toast to the log entry. The request id is in the HTTP response of a fault, but the toast does not show it.
- The refusing handler's own line.
EventResult.Cancelcarries no stack. The code button points at the script that started the operation, not at thereturn EventResult.Cancel(...)that refused it.
Where next#
- Refuse a save: the save-refusal recipe (
EventResult.Cancel, resources, HTTP 409). - Chain of Command: how an event handler in another
model refuses through
next. - Asking the user from a command: when the answer is a question to the user, not a refusal.
- The command run journal: where refusals and faults of commands are recorded.