Day 10 — Error handling and making flows reliable
A flow that works is easy. A flow that tells you when it did not work is the professional one.
What happens when an action fails
By default, an action fails and everything after it is skipped. The run is marked Failed, and the only person who finds out is the flow owner, by email, eventually.
{
"trigger": { "type": "manual", "body": { "Fee": 12000, "Students": 0 } },
"actions": [
{ "name": "Start", "type": "Compose", "input": "Beginning the calculation" },
{ "name": "Fee per student", "type": "Compose",
"input": "@div(triggerBody()?['Fee'], triggerBody()?['Students'])" },
{ "name": "Never reached", "type": "Compose", "input": "This action is skipped" },
{ "name": "Also skipped", "type": "SendEmail", "to": "a@b.com",
"subject": "Report", "body": "Done" }
]
}
Dividing by zero fails. Everything below it is marked Skipped, and the email nobody received is the part that hurts.
Configure run after - the try/catch
Every action can be told to run after another one, and on which outcomes: succeeded, failed, skipped, timed out. Setting an action to run only when the previous one failed is Power Automate’s version of catch.
Scopes
A Scope groups actions into a block. Combine that with run-after and you get a genuine try/catch: if anything inside the Try scope fails, the whole scope is failed, and the Catch scope runs.
Scope: Catch (run after Try has failed) → email somebody, log it
Scope: Finally (run after Catch is successful or is skipped) → tidy up
{
"trigger": { "type": "manual", "body": { "Fee": 12000, "Students": 4 } },
"actions": [
{
"name": "Try",
"type": "Scope",
"actions": [
{
"name": "Any students?",
"type": "Condition",
"expression": "@greater(triggerBody()?['Students'], 0)",
"yes": [
{ "name": "Fee per student", "type": "Compose",
"input": "@div(triggerBody()?['Fee'], triggerBody()?['Students'])" }
],
"no": [
{ "name": "No students yet", "type": "Compose", "input": 0 }
]
}
]
},
{
"name": "Finally",
"type": "Scope",
"actions": [
{ "name": "Log the run", "type": "Compose",
"input": "Finished at @{formatDateTime(utcNow(), 'dd MMM HH:mm')}" }
]
}
]
}
Change Students to 0 and run it. The Condition takes the If no branch, the division never happens, and the flow completes instead of dying.
Why if() is not a guard
div(fee, 0) still runs and still fails - and the error message points at the division, not at the if, which makes it maddening to debug.Run the second line in the box below and watch it fail even though the test says zero.
@if(empty(triggerBody()?['Rows']), 'No rows today', 'Got rows') @if(equals(triggerBody()?['Students'],0), 0, div(1000, triggerBody()?['Students'])) @int(replace(triggerBody()?['Fee'], ',', '')) @coalesce(triggerBody()?['Manager'], 'unassigned')
Lines one, three and four are safe. Line two is the trap, and the error it produces is the whole point of this section.
div(fee, max(createArray(students, 1))) can never divide by zero, because the divisor is at least 1.Guard the value, do not catch the error
- A field is missing →
coalesce(field, 'default') - A list is empty → test
empty(list)in a Condition before you touchfirst(list) - A number is zero or is really text → a Condition, or make it safe with
max()orreplace()
@coalesce(triggerBody()?['Manager'], 'unassigned') @empty(triggerBody()?['Rows']) @int(replace(triggerBody()?['Fee'], ',', '')) @div(1000, max(createArray(triggerBody()?['Students'], 1)))
The third line is worth noting: a fee that arrives as the text "12,000" cannot be used as a number until the comma is gone. Data from a spreadsheet does this constantly.
Terminate - failing on purpose
Sometimes the right answer is to stop. Terminate ends the run and lets you set the status, so the run history shows why rather than showing a green tick on a flow that did nothing.
{
"trigger": { "type": "manual", "body": { "Rows": [] } },
"actions": [
{
"name": "Anything to do?",
"type": "Condition",
"expression": "@empty(triggerBody()?['Rows'])",
"yes": [
{ "name": "Nothing today", "type": "Terminate", "status": "Cancelled",
"message": "No rows to process - stopping cleanly" }
],
"no": []
},
{ "name": "Build report", "type": "Compose", "input": "Processing the rows" },
{ "name": "Send it", "type": "SendEmail", "to": "team@dai-academy.com",
"subject": "Daily report", "body": "Attached" }
]
}
| Status | Use when |
|---|---|
| Succeeded | Nothing to do, and that is normal. A quiet Sunday. |
| Cancelled | Stopped deliberately. Shows separately in the history. |
| Failed | Something genuinely wrong that a person must look at. |
Retries
Connector actions retry automatically - four times, at increasing intervals, on the errors worth retrying. You can change this in the action’s settings.
| Policy | When |
|---|---|
| Default (exponential) | Leave it alone almost always |
| Fixed interval | A system that needs a steady pause |
| None | Anything that must not happen twice - a payment, a message to a customer |
A reliability checklist
- Name every action properly. "Compose 4" tells you nothing at 3am, and any expression pointing at it is unreadable.
- Guard every field that a human could leave blank.
- Wrap the risky part in a Try scope with a Catch that emails a named person - not just the flow owner.
- Include the run link in that email so whoever gets it can open the failure directly.
- Terminate early when there is nothing to do, with an honest status.
- Test the empty case. Zero rows, missing field, blank name. That is where flows actually break.
Try these yourself
- Make a flow fail on purpose and confirm the later actions are marked Skipped.
- Guard a division so a zero cannot bring the flow down.
- Wrap two actions in a Try scope and add a Finally scope after it.
- Terminate a flow as Cancelled when the incoming list is empty.
- List three things you would guard in a flow that reads a spreadsheet.
