ZuloOne
English
Sign in
Menu

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.

← Developer wiki

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.ShortageAsync does 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:

  1. BusinessRuleException anywhere in the chain. Its message, unchanged.

  2. A plain InvalidOperationException thrown by our code. Its message, unchanged. "Our code" means the throwing method lives in a ZuloOne assembly or a compiled script, not in a System.*, Microsoft.* or Npgsql assembly. The framework's own "Sequence contains no elements" is a fault, not a rule.

  3. 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 overflow 22008 fill in or fix the date
    Deadlock, timeout, aborted transaction 1205, -2 40P01, 57014 the server was busy, retry
  4. 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 without errorDetails as 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 a details field that looks like a stack trace, and falls back to a message field. 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:

  1. 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.
  2. If the executor turned a thrown handler into EventResult.Error(text), the stack would be lost. The result keeps the exception, and ScriptFaultException.For(reason, exception) rethrows it inside: as a BusinessRuleException when the handler refused, and as a ScriptFaultException when it crashed. A crash in a handler is therefore not shown as a yellow refusal.
  3. ScriptCodeLocator finds 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 NullReferenceException because 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. Search Tests/ for the old fragment before you rewrite a refusal.

InvalidOperationException from 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. Use FirstOrDefault() 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.Cancel carries no stack. The code button points at the script that started the operation, not at the return EventResult.Cancel(...) that refused it.

Where next#

Was this page helpful?