The error was caught. Who decides what happens next?
Why catching an exception does not establish a save outcome. A Dart experiment with ark_error_manager separates diagnostics, user notifications and retry decisions.
- Published
- September 16, 2026
- Verified
- September 14, 2026
The error was caught. Who decides what happens next?
A profile update ends with “Could not save changes.” The user tries again, the second request succeeds, and the application moves on. The exception was recorded, the interface offered a way forward, and nothing appears to have escaped the error handler.
There is still an unanswered question: what failed during the first attempt? Sending the request, applying the update, or receiving confirmation after the update had already been applied? Those failures can look similar to the caller while requiring different decisions.
A central error handler may receive an exception, a stack trace, an operation name and environment details. That is useful diagnostic information. It does not necessarily establish whether the profile changed or whether another request is safe. Installing an error handler provides a destination for an error; it does not automatically transfer knowledge of the operation to that destination.
The opposite rule—global handlers must never do anything except log—is not particularly useful either. Centralized recovery can be appropriate when the application supplies the necessary context and explicitly defines what recovery is allowed to do. The important boundary concerns knowledge and authority, not where a class happens to live.
A failure that does not reveal the outcome
The accompanying Dart example uses a controlled store rather than a real server. It can accept a change, return a business rejection, or throw a transport exception before or after changing its value. The test can inspect both sides; the caller cannot inspect the store's selected failure mode.
Here is the relevant method. The mode field selects fixture behavior, while value represents the stored profile value:
Future<bool> save(String input) async {
requests++;
if (mode == Mode.rejected) return false;
if (mode == Mode.beforeCommit) throw TransportFailure();
value = input;
if (mode == Mode.afterCommit) throw TransportFailure();
return true;
}
Both transport-failure branches throw the same exception type. In one, the old value remains. In the other, the new value has already been written. The fixture deliberately makes this distinction observable to the test without exposing it to the operation handler.
The client therefore records one of three outcomes:
enum Outcome { saved, rejected, unknown }
saved means confirmation was received. rejected means the operation returned a defined refusal. unknown means the caller lacks sufficient information to establish the outcome. It is neither a successful update nor an asserted rollback. It describes what the caller knows, rather than adding another transport-error category.
That distinction affects wording as well as control flow. “Could not confirm the update” communicates something different from “The data was not saved.” The next action might be a state read, a request-status lookup or a retry permitted by the operation's contract. The example does not retry automatically. That is a deliberate experimental boundary, not a general prohibition on retries.
Repeated assignment may be acceptable for one operation, while an operation with additional side effects needs stronger safeguards. A transport exception alone does not establish those safeguards. Even a Retry button implements an application policy; it is not merely a convenient way to dismiss an error.
Keeping the operation's meaning
The object coordinating the save has access to the submitted value and the expected result. In this example it is called SaveFlow, a small object with a run method. It is not a ViewModel or a particular Flutter state-management abstraction. It calls the store, records the outcome and accounts for the operation's local notification.
A business rejection is represented by false in this fixture. A production application would usually need a richer reason: a validation failure, insufficient permission or a stale data revision. The experiment only needs to distinguish a definite rejection from uncertainty. It does not automatically treat a rejected update as an application crash.
The expected transport exception is caught next to the operation. SaveFlow retains the input, records unknown, and submits the exception, stack trace and operation name to diagnostics. The profile contents are not passed into that diagnostic call. This limits what this particular call submits; it is not a privacy audit of the entire application or reporting pipeline.
Input retention is intentionally modest here. The input is an immutable field and there is no real form. The check establishes that this object keeps the value, not that a Flutter screen survives route disposal or that a draft survives process termination.
A central coordinator could own these decisions instead. It would need an explicit contract describing the operation, the known outcome, the permitted next steps and the component responsible for updating state. Moving a decision to a shared service is not inherently wrong. Sending only an exception and expecting that service to reconstruct the rest is the questionable part.
A shared policy without an invented business result
This separation can be implemented with application-specific adapters or with a library that exposes distinct responsibilities. The experiment uses ark_error_manager 1.0.0 from ArkTelos. Its role is to process the diagnostic incident according to application policy, not to determine whether the profile was saved.
The package does more than logging. ErrorPolicy selects an action plan. ErrorReporter records or transmits a prepared report, and ErrorPresenter performs user-facing presentation. ErrorRecoveryController carries out host-defined recovery. The API includes scope-reset, application-reset and termination directives. Those options do not mean the library independently knows when invoking them is appropriate.
For the save failure already handled by the operation, the experiment selects this plan:
ErrorActionPlan(
reporting: ErrorReportingDirective.record,
presentation: ErrorPresentationDirective.none,
recovery: ErrorRecoveryDirective.none,
)
Diagnostics should record the incident. They should not add a second notification or initiate global recovery. The operation remains responsible for its local notice. Another incident could legitimately require another plan; this is not proposed as the default for every error in an application.
The manager's own return contracts also matter. captureError reports whether an incident was accepted for processing. A true result does not establish successful report delivery, much less successful profile storage. handleError allows its caller to await the planned pipeline actions, but that still is not the business operation's result. The distinction is visible in the pinned implementation.
Two notifications without a repeated request
In the intended configuration, the operation accounts for one local notice and the manager uses presentation: none. The negative control changes that directive to passive, causing the configured ErrorPresenter to present the incident as well.
There is still one store request and one diagnostic submission. There are now two notices. No widget rebuild, repeated subscription or network retry is needed to produce the duplication. Two components independently decided to explain the same outcome.
The experiment models presentation with counters rather than actual snackbars. Within that boundary, the cause is directly observable: changing the policy adds the second presentation call. Returning to none removes it without disabling reporting.
This does not test general deduplication. Submitting the same incident repeatedly is a different scenario. The result concerns ownership of presentation for one handled outcome, not delivery guarantees across screen lifecycles or event replays.
Diagnostics can fail too
One case makes ErrorReporter throw instead of completing its work. A separate ErrorPipelineFailureHandler receives the pipeline failure. The recorded outcome remains unknown, the store request count remains one, and the operation still accounts for one local notice. The fallback counter increases once.
The failure to report does not turn the save into a success or trigger another save attempt. It also would not be accurate to say that no diagnostic information was lost: report delivery was not successful. What the case establishes is a separate response path for failure of one pipeline stage.
Another case closes the manager before the operation runs. captureError returns false, and the caller records that rejection. This makes failed intake visible instead of treating every manager invocation as guaranteed processing. It does not test queue saturation, durable diagnostic storage or recovery after a process crash.
What the checks actually establish
The eight cases ran twice in one process using Dart 3.12.2 on macOS arm64. Their outcomes matched, and the analyzer reported no issues. The recorded output includes the client outcome, fixture value and counters.
| Case | Client outcome | Stored value | Notices |
|---|---|---|---|
| Confirmed save | saved | new | 1 |
| Business rejection | rejected | old | 1 |
| Failure before the write | unknown | old | 1 |
| Write followed by lost response | unknown | new | 1 |
| Reporter failure | unknown | old | 1 |
| Local presentation only | unknown | old | 1 |
| Local and global presentation | unknown | old | 2 |
| Closed manager | unknown | old | 1 |
The local-presentation case repeats the baseline pre-write failure configuration to make the comparison with duplicate presentation explicit. These are not eight independent defect classes or sixteen distinct tests.
The two transport-failure cases also read the current store value after the failed call. One returns the old value and the other the new one. The historical unknown outcome is left unchanged. This fixture assumes a successful authoritative read with no competing writers. It does not model a stale replica or another network failure. In a concurrent system, finding the submitted value does not by itself prove that this particular request wrote it.
The distinction limits how far the result should travel. The example demonstrates a lack of information and an explicit allocation of responsibilities. It does not implement a complete reconciliation protocol, prove retry safety or provide an exactly-once guarantee.
Where Flutter's global hooks fit
Global capture points remain necessary for errors that reach them. Flutter routes framework-caught errors to FlutterError.onError; unhandled errors outside those callbacks use a different path. The Flutter error-handling guide explains this split.
PlatformDispatcher.onError handles unhandled errors in the root isolate. Errors in child isolates are not sent there directly. Its Boolean return value signals error handling to that mechanism, not successful completion of a user operation. The API contract documents those boundaries.
ark_error_manager_flutter connects the Flutter hooks to the manager. Its binding preserves previous handlers by default. When integrating an existing crash reporter, that chain needs inspection: preserving the earlier handler may be intentional, but it does not guarantee that a report is submitted only once. This integration was examined through code and documentation, not executed by the Dart experiment.
A practical review question
Following an exception to a global handler is only part of reviewing error handling. The decision path matters too: who knows whether the update was applied, who retains the input, who permits another attempt, who informs the user, and what happens if diagnostics fail?
If all those answers are supposedly contained in one catch block, check whether that block actually has the information it needs. Conversely, if a shared coordinator receives sufficient context and applies an explicit policy, moving its logic into individual screens merely to make it local serves little purpose.
Catching an exception is an observable event. Completing the application's response requires another decision: what is known about the operation, and what action does that knowledge justify? That contract deserves attention before the notification wording or the next automatic retry is added.
More engineering articles: ArkTelos Lab. Packages and project news: the official ArkTelos website.
Channels: Lab EN | Lab RU | ArkTelos EN | ArkTelos RU.
Result boundaries
- Controlled storage and notification counters, not real networking or UI. Eight cases, two passes in one process; S03 and S06 repeat the baseline. Concurrent writes, Flutter hooks, process crashes, capacity exhaustion and retry safety were not tested. Packaging did not run the experiment again.
CODE / DATA / AGENTS
Related experiments
global-error-handler-boundaryExamine unknown save outcomes, notification ownership and diagnostic failures.
Controlled storage and notification counters, not real networking or UI. Eight cases, two passes in one process; S03 and S06 repeat the baseline. Concurrent writes, Flutter hooks, process crashes, capacity exhaustion and retry safety were not tested. Packaging did not run the experiment again.
Open the experiment record →Telegram edition