Summary#
An action can fail. A security rule refuses the write, a constraint rejects the row, the server is unreachable — and
without somewhere to put that, the person who pressed the button sees a button that did nothing. Failure is where
the platform puts it:
component SaveBar() {
render {
if (Failure.Any) { Alert(Failure.Message, tone: Tone.Danger); }
}
}Failure is bound into every render scope and is part of the core baseline, so it needs no using and nothing is
declared to switch it on. It is filled by the runtime whenever an action fails, and it holds the most recent
failure until something clears it.
Signature#
Failure.Any // bool — did the last action fail, and has nobody dismissed it yet?
Failure.Message // string — what went wrong, in the words of whatever refused it
Failure.CorrelationId // string — the id to paste into `osy logs --correlation <id>`
Failure.Action // string — the action that failed, as `ui:<Component>.<Action>`
Failure.Dismiss // Action — clear it; `Failure.Any` goes falseDescription#
What fills it, and in whose words#
Any action that fails fills it — an onPress, an onChange, a lifecycle hook, anything the page RUNS. The message is
taken from whatever actually refused the call, in that thing's own words, because those are the words that say
something useful:
| what failed | what Failure.Message reads |
|---|---|
| a security rule | the rule's own sentence — "Create of 'Invoice' denied — no create rule is active for this caller…" |
| a constraint or an invariant | the author's own message from the declaration |
a throw in your function | the exception's message |
| the transport, and only then | the HTTP wording — "POST /fn/save failed: 503" |
The transport wording is the last resort rather than the first. A denial that reached the user as 403 would be
technically true and useless; the rule that refused it already wrote a sentence meant for a person, and that is the one
that surfaces.
Showing it once, not once per frame#
Failure.Any is a flag, not a stream. It is raised once per cause and stays raised until dismissed or until the
next failure replaces it — so a surface bound to it appears once and stays, rather than four copies arriving a second
apart. Binding a banner's visibility straight to Failure.Any is therefore the whole of the work.
Clearing it, so a banner is not permanent#
Failure.Dismiss is a verb the platform supplies, not one you write: the value lives in the client runtime, so
only the runtime can clear it. It is declared as an Action, which means it goes wherever an action is wanted —
onPress: Failure.Dismiss — and needs no wrapper:
component DismissibleFailure() {
render {
if (Failure.Any) {
Row(gap: 8, align: Align.Center) {
Alert(Failure.Message, tone: Tone.Danger);
Button("Dismiss", onPress: Failure.Dismiss, tone: Tone.Ghost);
}
}
}
}Without a dismiss the banner persists until the next navigation, which is a reasonable default and a poor experience on a page the user then goes on working in.
Giving the user something to report#
Failure.CorrelationId is the id that ties this failure to its server-side log line. Surfacing it rather than only
logging it is worth the pixels: a failure a user cannot report is most of the way to a failure nobody can fix, and
osy logs --correlation <id> turns a screenshot into a diagnosis.
component FailureNote() {
render {
if (Failure.Any) {
Stack(gap: 4) {
Text(Failure.Message, fontWeight: "600");
Text("Reference: " + Failure.CorrelationId, fontSize: "0.85rem", opacity: "0.7");
}
}
}
}Failure.Action names the action that failed, as ui:<Component>.<Action> — for a surface that wants to say which
verb it was, or to show a failure only for one particular action.
Three failure surfaces, and which one you want#
Three things can go wrong and they are deliberately separate, because replacing the whole screen for a failed button would be a lie about what broke:
| what broke | the surface | what the user sees |
|---|---|---|
| an action on a page that rendered fine | Failure — this page | the page still works; a message says the action did not |
| a query a region reads | A failing query | that region shows the failure in place; the rest of the page is fine |
| the page itself failed to load | app.Ui.ErrorSurface (UI surfaces (app.Ui)) | the page is replaced |
Field-level rejections are different again: a ValidationException carries per-field violations you place beside the
offending field, which is Validation. Failure is the catch-all for the action as a whole.
What is drawn when you draw nothing#
A page that reads Failure nowhere is not left silent: the platform shows a plain notice at the foot of the screen —
the message, the correlation id and a Dismiss — so a refused action never looks like a button that did nothing.
It is plain HTML with osy- classes (.osy-action-failed, -message, -correlation, -dismiss), restyled in the
app's CSS. While it is up the page gets room at its foot as tall as it, and the row of the control that was pressed is
scrolled clear of it, so it never covers what it is about.
Reading Failure anywhere takes the surface over: the platform's notice stands down for the whole page, and
whether the failure becomes a banner, a toast, a line under the form or nothing at all is the app's to decide.
A save that lost a race on a [ConcurrencyCheck] row gets a notice of its own — what you wrote, what the row holds
now, Keep theirs and Apply mine: The conflict notice — when a page's save meets somebody else's change.
See also#
- The conflict notice — when a page's save meets somebody else's change — the notice for a save that met somebody else's change: both values, and the two ways on.
- A failing query — the sibling surface for a failed READ, shown in the region that reads it.
- UI surfaces (app.Ui) —
app.Ui.ErrorSurface, for a page that failed to load rather than an action that failed. - Validation — per-field rejections, placed beside the field that was refused.
- Pending — the other half of action feedback: what the user sees while it is still running.
- Connection — the connection-loss overlay, for when the server is gone rather than refusing.