Handling failures
Failures in FlowTask go through two paths: cancellation and exceptions. This page explains how to write each kind of failure, where exceptions go, exceptions that nothing caught (unhandled exceptions) and OnUnhandledException, misuse the library detects, and cleanup when you close the World.
Two paths
Section titled “Two paths”| Kind | How it happens | Scopes along the way | Where it ends up |
|---|---|---|---|
| Cancellation | The parent ends, a Race is lost, handle.Cancel() |
Unwind with FlowCanceledException |
Not delivered as an exception. You can tell from the handle’s Status and CancelCause, the FlowJoinException of Join, and the Canceled AsTask() Task |
| Exception | throw, a failure in an awaited child or bridge, misuse detected by the library |
Climbs the chain of awaits like an ordinary exception | The awaiting caller. If nothing catches it, the World’s OnUnhandledException |
Cancellation is explained in Scopes and cancellation. This page covers exceptions. You can also return an expected failure as a return value; a return value is an ordinary value that goes through neither path.
Which one to use
Section titled “Which one to use”- Bugs on the calling side (invalid arguments, broken invariants, null references): throw an exception. If nothing catches it, it becomes an unhandled exception.
- Expected failures that the caller handles right away (network failures, failed purchases): throw a dedicated exception type and
catchit in the caller, or return a value that describes the outcome (see “Expected failures” below). - Interruptions that abort a whole screen or stage of progress, where the outer code decides where to go back to (maintenance, an expired session): throw a dedicated exception type, and
catchthat type where you want to return to. - The user interrupting (the back key, a cancel button):
FlowTask.Racethe work being interrupted against that input. The losing branch unwinds (Composition). - Exceptions coming from outside (Task, SDKs):
catchthem where they are awaited, and turn them into the form of item 2 if needed.
Exceptions
Section titled “Exceptions”An awaited child’s exception is rethrown at that await, just as with Task. If nothing catches it, it climbs the chain of awaits, and each scope along the way ends with that exception (FlowStatus.Faulted). If nothing catches it anywhere, it reaches the World’s OnUnhandledException.
async FlowTask ShopScreen(){ while (true) { var item = await buyClicked.Next(); try { await Purchase(item); } catch (Exception e) when (e is not FlowCanceledException) { ShowError(e); // a bug in the store SDK ends this purchase, not the screen } }}- If you wrap work started from a button in
try/catch, a single SDK bug won’t stop the screen or the whole game. - Always add
when (e is not FlowCanceledException)to acatchthat catches everything. Without it, you swallow the cancellation (FLOW001; Scopes and cancellation). - When a branch of
FlowTask.RaceorFlowTask.WhenAllends with an exception, the combinator finishes unwinding the other branches (including their cleanup) and then rethrows that exception. - Failures are delivered through the same resume queue as value completions, so they obey Pause too.
- The stack trace of an exception rethrown at an await contains only the place it was first thrown and the place it was caught. See the steps in between with
FlowExceptionInfo.ScopePath(Debugging).
Expected failures
Section titled “Expected failures”sealed class ApiException : Exception{ public ApiException(ApiError error, Exception inner) : base(error.ToString(), inner) => Error = error; public ApiError Error { get; }}
async FlowTask<Stage> FetchStage(){ string json; try { json = await FlowBridge.FromTask(ct => http.GetStage(ct)); } catch (HttpRequestException e) { throw new ApiException(ApiError.Network, e); } catch (OperationCanceledException e) when (e is not FlowCanceledException) { throw new ApiException(ApiError.Timeout, e); // HttpClient's timeout, or a source canceled outside the flow }
return Parse(json);}
// the callertry{ ShowStage(await FetchStage());}catch (ApiException e){ ShowError(e.Error);}- Turn the external exception into a type that describes the failure of that call, and throw it. The caller catches only that type. SDK bugs (
NullReferenceExceptionand the like) pass through unchanged. - With only
catch (HttpRequestException), an HttpClient timeout (TaskCanceledException) isn’t caught and becomes an unhandled exception. For external cancellations, see the “Tasks canceled from outside” section below. - If the caller branches on the failure every time, you can return a value that describes the outcome instead of throwing (an
enum, a type holding the value and the reason for the failure, or the Result type you already use). A return value is an ordinary value, so the rules in “Exceptions that can’t be delivered” below don’t affect it.
To run several operations side by side and stop the rest at the first failure, throw the failure as an exception and wait with FlowTask.WhenAll (Composition). To let the rest run to the end when one fails and collect the outcome of each branch, catch the exception inside each branch and return the outcome as a value.
Aborting and going back to a return point
Section titled “Aborting and going back to a return point”sealed class SessionExpiredException : Exception { }
async FlowTask Game(){ while (true) { await Title(); try { await InGame(); } catch (SessionExpiredException) { await ShowNotice("Session expired"); } }}- The throwing side doesn’t resume. Scopes along the way end with the exception, and
usingandfinallyalways run. - A catch-all along the way (
catch (Exception e) when (e is not FlowCanceledException)) also catches this exception. A catch-all inside the return point should log andthrow;, or exclude the type withwhen. - For an interruption that happens independently of any await (such as a session-expired notification), Race near the return point and turn it into an exception.
Exceptions from spawned children
Section titled “Exceptions from spawned children”When a child started with Flow.Spawn ends with an exception, the scope that spawned it (the owner) is canceled (CancelCause.Fault). After the owner has unwound, the exception is rethrown at the caller awaiting the owner.
async FlowTask Battle(){ _ = Flow.Spawn(EnemyWave()); // if EnemyWave throws, Battle is unwound... await PlayerTurn();}
async FlowTask InGame(){ try { await Battle(); } catch (WaveException) { ShowWaveError(); } // ...and the exception arrives here}- The owner’s
catchreceives aFlowCanceledException, and itsfinallyruns. - The child’s exception doesn’t reach a
catchin the same method that surrounds the Spawn. Extract the range where you want to receive it into its own method. - An exception from a root flow started with
FlowWorld.Rungoes toOnUnhandledException. For a child that shouldn’t drag its owner down, usetry/catchinside the child’s method, or start it withFlowWorld.Run. - The child’s handle also holds the exception (
Exception; theAsTask()Task is Faulted with that exception).
OnUnhandledException and unhandled exceptions
Section titled “OnUnhandledException and unhandled exceptions”An exception that nothing caught and that reached the World’s root is called an unhandled exception. Unhandled exceptions are reported to FlowWorld.OnUnhandledException as FlowExceptionInfo.
world.OnUnhandledException = info =>{ if (info.Kind == FlowExceptionKind.Undelivered) Log.Warning(info.ToString()); else Log.Error(info.ToString()); // "[Unhandled] at Game > InGame > Battle: System.InvalidOperationException: ..."};FlowExceptionInfo has Exception, ScopePath (the path from the root at the time the exception was thrown, in the form Game > InGame > Battle), and Kind.
FlowExceptionKind |
What it is |
|---|---|
Unhandled |
An exception that nothing caught: an exception that ended a flow started with FlowWorld.Run, or an exception from code outside flows (Posted work, handlers) |
Cleanup |
An exception in cleanup: a new exception thrown by a canceled scope’s catch or finally, or an exception from AddCleanup, the Dispose of Own, or onDiscard. The rest of the cleanup goes on |
SwallowedCancellation |
A canceled scope returned after receiving the cancellation (swallowed it). The scope ends as canceled |
Undelivered |
An exception that couldn’t be delivered to its receiver (see the section below) |
Kinds other than Unhandled are report-only and don’t change the flow of control.
- In a World without
OnUnhandledException, the reports are thrown as aFlowUnhandledException(listed inExceptionInfos) from the outermostTick,Flush,Run, orDispose, after it has finished its work. An exception thrown by the handler is also thrown in the same way, together with the report it was given. - Reports are delivered immediately, in the order they are found. So a cleanup failure (
Cleanup) can arrive before the exception that caused it. OnUnhandledExceptionandOnWarninghandlers are called in the middle of the scheduler (while it ends a scope or unwinds one). A flow you start or cancel there runs in the middle of that work, so keep handlers to logging and counting. To respond with a flow, such as showing an error screen, hand it to the next Tick or Flush intake withworld.Post(() => world.Run(ShowError())), or tell a flow that waits for it through a Signal. You can’t callTick,Flush, orDispose.- The Unity and Godot integrations set a default handler that logs
Undeliveredas a warning and everything else as an error. FlowHandle.Exceptionis the exception that ended the task. It has a value only when the task ended with an exception (Faulted). The path where the exception was thrown, and the report-only kinds, come throughOnUnhandledException.
Exceptions that can’t be delivered (Undelivered)
Section titled “Exceptions that can’t be delivered (Undelivered)”Sometimes the party that should receive an exception has already been canceled, or has already settled. Such an exception isn’t carried; it’s reported to OnUnhandledException as Undelivered, and the outcome doesn’t change.
| Situation | What happens |
|---|---|
A child’s exception arrived at a waiter whose cancellation has been decided (but that hasn’t started unwinding yet). For example, a screen that failed while paused and was then closed, World.Dispose, or a Cancel that arrived in the same intake |
The waiter gets only a FlowCanceledException (the catch never sees the exception). The scope ends as canceled |
| An exception received by a cleanup await in a canceled scope left the scope uncaught | The scope ends as canceled |
An in-flight exception rethrown by a finally entered before the cancel, after its Flow.NonCancelable await |
The scope ends as canceled |
| An exception that arrived later at a Race that already has a winner, or at a WhenAll already settled by a failure | Doesn’t change the outcome |
| The second and later exceptions (from siblings after a Race or WhenAll settled on the first exception) | The main one is whichever was processed first |
| An exception from a spawned child of an owner that is finishing or was canceled | The child’s handle holds the exception |
| A Task that failed after the bridge stopped waiting for it | The exception is marked as observed, so it doesn’t become an UnobservedTaskException |
Undelivered happens in normal play too (two network calls failing in the same frame, for example). If an expected failure becoming Undelivered instead of reaching your catch is a problem, return that failure as a value instead of throwing it (how to stop operations run side by side at the first failure is in “Things written by combination” in Design rationale).
Tasks canceled from outside
Section titled “Tasks canceled from outside”When a bridged Task is canceled from outside (an HttpClient timeout, a CancellationToken passed in from outside, a UniTask cancellation), it isn’t a flow cancellation. It’s an ordinary exception thrown from the bridge’s await. If nothing catches it, it’s an unhandled exception. Catch it with catch (OperationCanceledException e) when (e is not FlowCanceledException), as in FetchStage above.
When the scope itself is canceled, the bridge unwinds without reading the result, so this catch isn’t reached. Details, such as cancellations that catch (TaskCanceledException) can’t catch, are in Task and ValueTask.
Misuse the library detects
Section titled “Misuse the library detects”Misuse the library detects at run time becomes an ordinary exception of the scope, FlowMisuseException. It’s thrown from the waiter’s await, and you can catch it with catch.
| Misuse | What happens | How to fix |
|---|---|---|
A FlowTask method suspended on an external awaitable that wasn’t bridged (Task, UniTask, Unity’s Awaitable, Godot’s SignalAwaiter, Task.Yield(), and so on) |
The scope ends immediately. The remaining finally and using don’t run (AddCleanup and Own do) |
Go through .AsFlow() or FlowBridge.FromTask. Replace Task.Yield() with FlowTask.NextFrame(), and Task.Delay with FlowTask.WaitForSeconds (FLOW002) |
An async method that isn’t a FlowTask method (async Task, UniTask, async void) awaited a FlowTask and suspended |
That method never resumes, and its Task never completes. The calling scope unwinds and then ends with this exception | Make the method return FlowTask. To wait from outside a flow, use world.Run(task).AsTask() (FLOW005) |
| The same FlowTask was started twice (awaited twice, passed to two combinators), or awaited outside a flow | That await throws | Flows and the World, Composition |
| One scope completed more than 1,000,000 awaits synchronously in one Tick, Flush, or Run | Treated as an infinite loop; the scope ends at that await. Awaits of already completed values (FlowTask.FromResult, CompletedTask) aren’t counted, so a loop that only spins on those never returns from Tick, Flush, or Run |
Put await FlowTask.NextFrame() in loops that wait |
You kept a FlowCanceledException you received and rethrew it in a scope that wasn’t canceled (only the library can create this exception) |
The scope ends with an exception that has it as its InnerException | Rethrow a cancellation only with throw; inside the catch that received it |
In addition, when a subscription with BufferOverflow.Fail overflows, the scope ends with SubscriptionOverflowException (Signals). Use from another thread is FlowThreadException (derived from FlowMisuseException) (Threads).
Note: A completed Task (such as a fake
Task.FromResultin a test) doesn’t suspend even if you await it directly, so it isn’t misuse at run time. It passes in tests and throws only in production. Wait on fake Tasks through a bridge too. FLOW002 stops both forms at compile time.
Messages of common exceptions and how to fix them are in Debugging.
Finishing cleanup before closing the World
Section titled “Finishing cleanup before closing the World”World.Dispose cancels every flow and unwinds them in one pass. Dispose doesn’t run Ticks, so awaits in the finally and catch of canceled scopes (including Flow.NonCancelable awaits) throw FlowCanceledException again, and each block runs only up to its first await. Outer finally, using, AddCleanup, and Own do run. When cleanup awaits are cut off, a CleanupCutAtDispose warning is raised.
If some cleanup must run to the end, such as saving, cancel the flows before Dispose, run Ticks with a limit, and Dispose after the flows have ended.
void CloseWorld(FlowWorld world, FlowHandle game){ game.Cancel(); // Tick until the cleanup has ended, for 2 seconds at most. for (var i = 0; i < 120 && game.Status == FlowStatus.Running; i++) world.Tick(1.0 / 60); world.Dispose(); // cuts what is still running, and warns (CleanupCutAtDispose)}FlowWorld.Runcalled from code running during Dispose (finally,AddCleanup,Own, handlers) doesn’t start the task and returns a handle that has already ended asCanceled.Flow.Spawndoesn’t start the task either.- During Dispose, swallowed cancellations aren’t reported.
- Anything sent from another thread after Dispose is dropped.
- How to write this for engine shutdown and scene changes is in Unity setup and Godot setup.
In depth: fine points of exception handling
Section titled “In depth: fine points of exception handling”These are rules you normally don’t need to think about. You can avoid most of them by not throwing exceptions in cleanup.
- An exception thrown by the
finallyof a live scope replaces the exception in progress (a C# rule). - During
World.Dispose, an exception thrown by thecatchorfinallyof a canceled scope is replaced by theFlowCanceledExceptionthat an outerfinally’s await throws again, and isn’t reported (a C# rule). - In the cleanup of a canceled scope, an exception you throw yourself is reported as
Cleanup, and an exception received from an await is reported asUndelivered. Just extracting a method can change the kind. - If an owner canceled by a spawned child’s failure exits with an exception before the
FlowCanceledExceptionreaches its code, that exception becomes the main one, and the child’s exception becomesUndelivered. An exception thrown by cleanup after the cancellation has arrived is reported asCleanup, and the child’s exception is the main one. - A Cancel that arrived in the same intake takes effect before a failure that arrived earlier (this happens with a Cancel from another thread).
- An exception that went through a Task is reported with the path of the bridge in the flow that received it. If a flow’s
AsTask()is awaited by another flow through a bridge and neither catches the exception, the same exception is reported twice. Have flows wait on each other withJoin.