🚀 New batches open: Advanced Excel • Power BI • SQL • AI for Analytics — Book a free demo

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.

Action → ... → Configure run after → tick has failed
Where to find it In the designer: the three dots on the action, then Configure run after. It is one of the most useful settings in the product and one of the best hidden.

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: Try → the real work
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

This does not protect you
@if(equals(students, 0), 0, div(fee, students))
It looks like it checks for zero first. It does not. Power Automate works out both arguments before it picks one, so 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.

So what does guard it? A Condition action, as in the Try scope above - the branch you do not take genuinely does not run. Failing that, make the dangerous value safe before you use it: 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

Prevention beats recovery Catching a failure tells you something broke. Guarding the input stops it breaking. Most Power Automate failures are one of three things:
  • A field is missing → coalesce(field, 'default')
  • A list is empty → test empty(list) in a Condition before you touch first(list)
  • A number is zero or is really text → a Condition, or make it safe with max() or replace()
@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" }
  ]
}
StatusUse when
SucceededNothing to do, and that is normal. A quiet Sunday.
CancelledStopped deliberately. Shows separately in the history.
FailedSomething genuinely wrong that a person must look at.
Choose the status honestly Terminating as Succeeded when something is actually broken hides the problem, and your run history stops being worth reading. If a person needs to act, fail it.

Retries

Connector actions retry automatically - four times, at increasing intervals, on the errors worth retrying. You can change this in the action’s settings.

PolicyWhen
Default (exponential)Leave it alone almost always
Fixed intervalA system that needs a steady pause
NoneAnything that must not happen twice - a payment, a message to a customer
Retries can duplicate If an action succeeded but the reply was lost, the retry does it again. Sending an email twice is embarrassing; creating an invoice twice is worse. For anything that must happen exactly once, turn retries off and handle the failure yourself.

A reliability checklist

  1. Name every action properly. "Compose 4" tells you nothing at 3am, and any expression pointing at it is unreadable.
  2. Guard every field that a human could leave blank.
  3. Wrap the risky part in a Try scope with a Catch that emails a named person - not just the flow owner.
  4. Include the run link in that email so whoever gets it can open the failure directly.
  5. Terminate early when there is nothing to do, with an honest status.
  6. Test the empty case. Zero rows, missing field, blank name. That is where flows actually break.

Try these yourself

  1. Make a flow fail on purpose and confirm the later actions are marked Skipped.
  2. Guard a division so a zero cannot bring the flow down.
  3. Wrap two actions in a Try scope and add a Finally scope after it.
  4. Terminate a flow as Cancelled when the incoming list is empty.
  5. List three things you would guard in a flow that reads a spreadsheet.