Azure Integration

Resilient Logic Apps: Retry Policies, Scopes, runAfter and Catch-Block Error Handling Patterns

A workflow that only works when every dependency is healthy is not a workflow — it is a demo. The moment you put a Logic App into production it talks to systems that throttle (HTTP 429), time out, restart, or blink for two seconds during a deployment. The default behaviour hides this: most actions already retry four times behind the scenes, so a flaky call looks fine until the day the downstream is down for thirty seconds — and because there was no catch block, the failure is silent (no alert, no compensation, no dead-letter, just a red run in a history blade nobody watches). This is the single most common reason a “working” integration loses a message in production.

This article is the resilience playbook for Azure Logic Apps — both Consumption (multi-tenant, per-action-billed) and Standard (single-tenant, App Service-hosted). It treats error handling as four primitives you compose deliberately: the retry policy on each action (how many times, how far apart, and which failures even qualify), the Scope action that groups steps into a unit you guard as a whole, the runAfter property — the one piece of workflow JSON that turns a linear flow into try / catch / finally by letting an action run after another action failed, was skipped, or timed out — and the Terminate action plus expressions like result() and @actions('Name') that let a catch block read exactly which step blew up and react. Master those four and any workflow degrades gracefully instead of dying.

By the end you will stop shipping happy-path-only workflows — knowing when a retry helps versus merely delays the inevitable, how to guard a transaction with a Scope and a catch that runs only on failure, how to read the failed action inside that catch, and how to dead-letter a poison message instead of losing it. Everything comes with the portal click-path, the az CLI, and the raw workflow JSON, because in Logic Apps the JSON is the source of truth and the designer is just a view over it.

What problem this solves

Logic Apps make integration deceptively easy: drag an HTTP action, point it at an API, drag a “Send to Service Bus” after it, and you have shipped a pipeline in ten minutes. What you have not done is decide what happens when the HTTP call returns 429, when Service Bus is mid-failover, or when the API returns a 200 with a body that says {"error":"validation failed"}. In the happy path none of that matters; in production all of it eventually happens, usually at 2am, usually on the run that mattered.

What breaks without deliberate handling cuts two ways. A transient 503 fails the whole run even though a retry two seconds later would have succeeded — you get paged for a fault that no longer exists. Or the opposite: the default retry masks a real outage until it exhausts, the run goes red, and because nothing runs runAfter a failure, no one is notified and the message — already consumed off its trigger queue — is gone. A workflow without a catch block can lose data; one that blindly retries non-idempotent writes does the reverse — charges a card twice, sends a duplicate email.

This bites every team past the prototype, hardest on workflows that fan out to SaaS APIs (rate limits, timeouts), consume from a queue (a lost message is a lost business event), or do multi-step writes (a halfway failure leaves the world inconsistent). The fix is never “hope it works”: retry the ones worth retrying, catch the rest, and make the catch do something — alert, compensate, or dead-letter.

The failure modes this article addresses, what goes wrong without handling, and the primitive that fixes each:

Failure mode What happens with no handling Primitive that fixes it Section
Transient fault (429/503/timeout) One blip fails the whole run Retry policy (exponential/fixed) Core concepts, Retry deep-dive
Real outage after retries exhausted Run goes red, nobody notified, message lost Scope + runAfter catch runAfter section, Catch patterns
Multi-step write fails halfway World left inconsistent, no rollback Scope grouping + compensation Scopes section
Need to read which step failed Catch block has no idea what broke result() / @actions() Reading the failure
Poison message that can never succeed Infinite retries or silent data loss Dead-letter / Terminate Dead-letter patterns
Business-logic error returned as 200 Treated as success, bad data flows on Condition + Terminate Failed Common mistakes

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be able to build a basic workflow — a trigger plus a couple of actions — as in How to Build Your First Logic App Workflow: Triggers, Actions, Connectors and Expressions. Know that a Logic App is defined by Workflow Definition Language (a JSON document of triggers and actions), that the designer is a view over that JSON, and that Code view lets you edit it directly. Comfort with az in Cloud Shell, reading JSON, and HTTP status codes is assumed — you do not need to be a developer; this is configuration and expressions, not application code.

This sits in the Integration track, one layer up from your first workflow, squarely in “make it production-grade.” The hosting-model choice — which changes pricing, networking and a few error-handling details — is in Logic Apps Consumption vs Standard: Pricing, Networking and Stateful Workflows Compared. The patterns here pair with a durable broker — if you consume from or dead-letter to a queue, Service Bus Queues vs Topics: Choosing Point-to-Point or Publish-Subscribe Without Regret is the upstream decision. And because a catch block is only useful if someone sees the failure, Azure Monitor and Application Insights: Full-Stack Observability and How to Create Your First Metric Alert and Action Group for Email, SMS and Push are how you wire failure to a human.

Ownership splits cleanly: the integration team owns the retry policies and the Scope/runAfter graph (over-retry causes duplicates; a missing catch causes silent failure), the platform team owns the Service Bus/Storage dead-letter target (poison messages, DLQ growth), the downstream/partner team owns the API that emits the 429/5xx the retry handles, and SRE owns the Azure Monitor alert without which a failure is invisible. Knowing the boundary tells you who to page.

Core concepts

Five mental models make every later pattern obvious.

Every action has a retry policy, and by default it is already retrying. Most connector actions — HTTP, Service Bus, SQL — ship with a default retry policy of exponential, 4 retries. That is good (transient blips self-heal) and dangerous (a non-idempotent call retried can duplicate side effects). A policy has three parts: a type (none, fixed, exponential), a count (0–90 retries), and an interval (an ISO-8601 duration like PT20S), living under inputs.retryPolicy.

Logic Apps only retries retriable failures. It does not re-run an action that returned a 400 — retrying a malformed request just fails again. The runtime retries on HTTP 408, 429, and 5xx (500–599) plus genuine connection/timeout errors; a 401/403/404/400/409 is a deterministic failure that fails immediately regardless of your count. This is why “I set retries to 10 and it still fails instantly” is almost always a 4xx.

A Scope is a single action containing other actions, and rolls their status up. A Scope groups a sequence into one logical unit with a status computed from its children: any unhandled child fails → Failed; all succeed → Succeeded; cancelled mid-Scope → Aborted/Cancelled. This roll-up is the lever for try/catch — you guard a whole transaction by watching the Scope’s status, not each action.

runAfter is the dependency edge — and the whole secret to catch blocks. Every action has a runAfter object naming the predecessor(s) and the statuses under which it runs. The default { "Previous": ["Succeeded"] } runs only if the previous action succeeded; ["Failed", "TimedOut"] makes it a catch block; all four statuses make it a finally block. The four statuses — Succeeded, Failed, Skipped, TimedOut — are the alphabet of error handling. There is no try/catch syntax in Logic Apps, only runAfter with the right statuses.

A catch block can read what failed, and a run can end with a chosen status. result('ScopeName') returns an array of every child action’s result — each with status, code, and error — so a catch finds which action failed and why, and @actions('ActionName') gives one action’s outputs and status. The Terminate action then ends the run with a status you choose plus a custom code and message, so run history (and any alert keyed on it) reflects reality instead of a generic failure.

The vocabulary in one table

Every moving part, side by side (the glossary repeats these for lookup):

Concept One-line definition Where it lives Why it matters to resilience
Action One step in a workflow definition.actions Each can retry, fail, be caught
Retry policy How an action re-attempts on transient faults inputs.retryPolicy Wrong policy → brittleness or duplicates
Retriable failure A fault the runtime will retry (408/429/5xx) Runtime behaviour Non-retriable (4xx) fails instantly
Scope A container action grouping children An action of type Scope The unit you guard as try/catch
runAfter Statuses of predecessors that gate this action Every action Turns linear flow into try/catch/finally
Status Succeeded / Failed / Skipped / TimedOut Per action at runtime The alphabet of runAfter
result() Results array of a scope’s child actions Expression Lets a catch read what failed
Terminate Ends the run with a chosen status An action Makes run history/alerts truthful
Dead-letter Routing a poison message aside Service Bus DLQ / storage Stops infinite retry and data loss
Idempotency Safe to repeat without extra side effects Your design Required before retrying writes

How the retry policy actually works

The retry policy is the first line of defence and the most misunderstood setting in Logic Apps — get it right and most transient faults vanish before they become an incident. Every retry type, what it does, and when to pick it:

The four retry types

Type Behaviour Use when Trade-off / limit
exponential (default) Waits a randomised, exponentially-growing interval between retries (jittered to avoid thundering herd) Default for almost everything; especially good against 429 throttling Total time before final failure can be large; less predictable timing
fixed Waits a constant interval between retries You want predictable, evenly-spaced retries (e.g. a downstream with a known recovery window) No back-off; can hammer a struggling dependency
none No retry — fail on first error The operation is non-idempotent and unsafe to repeat, or you want to handle the failure yourself immediately One transient blip fails the action
default Use the connector’s built-in policy (exponential, ~4 retries) You explicitly want the platform default and to signal that intent Same as exponential default

The count ranges from 0 to 90, the interval is an ISO-8601 duration, and exponential also takes a minimumInterval/maximumInterval to bound the back-off. Real defaults and limits worth memorising:

Property Default Min Max Notes
retryPolicy.type exponential On most connector actions
count (retries) 4 0 90 This is retries after the first attempt
interval PT7SPT20S (varies) PT5S PT1D ISO-8601 duration
minimumInterval (exponential) varies PT5S Lower bound of back-off
maximumInterval (exponential) varies PT1D Upper bound of back-off
Action timeout (limit.timeout) PT1MP30D (action-dependent) up to P30D long-running Separate from retry; caps a single attempt

Which failures get retried — and which never do

A retry policy only ever fires on a retriable status; a deterministic 4xx fails immediately no matter what count you set:

HTTP status / condition Retried? Why What to do instead
408 Request Timeout Yes Transient — the server didn’t answer in time Let it retry; maybe raise the per-action timeout
429 Too Many Requests Yes Throttling — back-off is exactly right Exponential retry; honour Retry-After if present
500–599 (5xx) Yes Server-side transient Retry; if persistent, catch and alert
Connection failure / socket reset Yes Network blip Retry
400 Bad Request No Deterministic — request is malformed Fix the request; don’t retry
401 / 403 No Auth/permission — won’t change on retry Fix credentials/RBAC
404 Not Found No Resource isn’t there Fix the URL/resource; catch if expected
409 Conflict No Logical conflict (e.g. duplicate) Handle in a catch; often means “already done”
Action none policy set No (by design) You opted out Handle failure in a catch immediately

A subtle one: a 429 with a Retry-After header tells you exactly when to come back. Exponential back-off respects the spirit of this, but a high-rate target like Azure OpenAI may want explicit handling — see Azure OpenAI 429 Rate-Limit Errors: TPM, RPM and Retry Handling.

Setting a retry policy — portal, JSON, az

Portal: open the action → Settings ( menu) → Retry Policy → choose Type, Count, Interval. In the workflow JSON, the policy is a child of the action’s inputs:

"HTTP_Call_Inventory": {
  "type": "Http",
  "inputs": {
    "method": "POST",
    "uri": "https://api.contoso.com/inventory",
    "body": "@triggerBody()",
    "retryPolicy": {
      "type": "exponential",
      "count": 4,
      "interval": "PT10S",
      "minimumInterval": "PT5S",
      "maximumInterval": "PT1H"
    }
  },
  "runAfter": {}
}

To turn retries off entirely on a non-idempotent action, use "retryPolicy": { "type": "none" }. In Bicep, a Consumption workflow embeds the whole definition and the identical retryPolicy block appears inside the action — see the complete Bicep workflow in the lab’s Part C.

When a retry is the wrong tool

A retry is a hammer; not everything is a nail. Before adding retries:

Situation Retry helps? Why / what to do instead
Downstream throttles you (429) Yes Exponential back-off is textbook-correct
Downstream is briefly down (5xx) Yes A few retries ride out a restart
Request is malformed (400) No Same input fails every time — fix the input
Non-idempotent write (charge card, send email) Only with an idempotency key Otherwise you duplicate the side effect
Auth failed (401/403) No Credentials won’t fix themselves — fail fast, alert
Business rule rejected it (200 with error body) No Not a transient fault — branch on the body
Long outage (minutes) Partially Retries waste time; prefer dead-letter + alert + reprocess later

The standout trap is the non-idempotent write: a retry after a lost-response timeout (the payment succeeded, the response didn’t) charges twice. Pass an idempotency key so the downstream deduplicates; if it cannot, set the policy to none and resolve the ambiguous timeout in a catch.

Grouping work with Scopes

A Scope is the container that makes try/catch possible. On its own it is just visual grouping; its status roll-up is what you exploit.

Scope status semantics

A Scope’s final status is computed from its children:

Child outcomes inside the Scope Scope status What it means downstream
All children Succeeded Succeeded The whole unit worked
Any child Failed (unhandled inside) Failed The transaction broke; trigger the catch
Children Skipped because an earlier one failed (contributes to Failed) Cascade — later steps never ran
Run cancelled / aborted mid-Scope Aborted / Cancelled Operator or platform stopped it
A child TimedOut Failed (TimedOut surfaces as the failure) A step exceeded its action timeout

Because the Scope aggregates, you attach one catch to the Scope rather than a catch to each action. That is the entire ergonomic win: guard the transaction, not every line.

What goes inside the try Scope

Put the steps that must succeed together inside one Scope — the multi-step write, the fan-out, the sequence where a half-finished result is bad — and keep outside it whatever should run regardless (logging the start, reading the trigger):

Element Inside try Scope? Reason
Read/parse the trigger payload Usually outside If this fails the run can’t proceed anyway
The core multi-step transaction Inside This is what you’re guarding as a unit
Calls with side effects (writes, sends) Inside A failure mid-way is what the catch compensates
Logging “started” Outside (before) Should always happen
The catch (compensation / alert) A separate Scope, runAfter the try Runs only on failure
The finally (cleanup) A separate Scope, runAfter both Runs regardless

Nesting and limits

Scopes nest, and you can put a Condition, a For-each, or another Scope inside one. Practical limits to respect:

Limit Value (typical) Why it matters
Max nesting depth of scopes/conditions 8 levels Deeply nested try/catch gets unreadable and hits the cap
Actions per workflow ~500 (Consumption soft limit) Huge workflows are a smell; split them
For-each parallelism up to 50 (configurable) concurrent iterations Affects how many retries fire at once
Switch/Condition cases bounded Branch logic inside a scope still counts toward depth

Nesting four Scopes deep is a sign to split the workflow into a parent calling child workflows (each its own try/catch), which also makes each unit independently retriable.

try / catch / finally with runAfter

This is the heart of the article. Logic Apps has no dedicated try/catch keyword — you build it from a try Scope, a catch Scope whose runAfter fires on failure, and an optional finally Scope whose runAfter fires on everything.

The runAfter status matrix

Every action’s runAfter maps predecessor names to the statuses under which this action runs. What each configuration gives you:

runAfter statuses Role Runs when…
["Succeeded"] Normal next step (default) The predecessor succeeded
["Failed", "TimedOut"] Catch block The predecessor failed or timed out
["Skipped"] Alt-path The predecessor was skipped (its own runAfter wasn’t met)
["Succeeded", "Failed", "Skipped", "TimedOut"] Finally block Always — regardless of predecessor outcome
["Failed"] only Strict catch Only on outright failure (not timeout)
["Succeeded", "Skipped"] Continue-on-skip Succeeded or the predecessor never ran

Building it in the designer

The mechanic is a small menu most people miss:

  1. Add a Scope action; rename it Try. Put your transaction inside it.
  2. Add a second Scope after it; rename it Catch.
  3. On the Catch Scope, open the menu → Configure run after.
  4. Untick is successful and tick has failed and has timed out. Save.
  5. (Optional) Add a third Scope Finally; Configure run after the Catch (or both Try and Catch) with all four boxes ticked.

That “Configure run after” dialog is the entire UI for runAfter — its tick-boxes are the four statuses.

Building it in JSON / Bicep

The same structure as raw workflow JSON — Catch_Scope’s runAfter names Try_Scope with Failed/TimedOut, and Finally_Scope runs after both with all statuses:

"actions": {
  "Try_Scope": {
    "type": "Scope",
    "actions": {
      "Reserve_Inventory": {
        "type": "Http",
        "inputs": { "method": "POST", "uri": "https://api.contoso.com/reserve", "body": "@triggerBody()" },
        "runAfter": {}
      },
      "Charge_Payment": {
        "type": "Http",
        "inputs": {
          "method": "POST",
          "uri": "https://api.contoso.com/charge",
          "headers": { "Idempotency-Key": "@triggerBody()?['orderId']" },
          "body": "@triggerBody()",
          "retryPolicy": { "type": "exponential", "count": 4, "interval": "PT10S" }
        },
        "runAfter": { "Reserve_Inventory": ["Succeeded"] }
      }
    },
    "runAfter": {}
  },
  "Catch_Scope": {
    "type": "Scope",
    "actions": {
      "Capture_Error": {
        "type": "Compose",
        "inputs": "@result('Try_Scope')",
        "runAfter": {}
      },
      "Terminate_Failed": {
        "type": "Terminate",
        "inputs": {
          "runStatus": "Failed",
          "runError": {
            "code": "TransactionFailed",
            "message": "@{first(result('Try_Scope'))?['error']?['message']}"
          }
        },
        "runAfter": { "Capture_Error": ["Succeeded"] }
      }
    },
    "runAfter": { "Try_Scope": ["Failed", "TimedOut"] }
  },
  "Finally_Scope": {
    "type": "Scope",
    "actions": {
      "Log_Completion": {
        "type": "Compose",
        "inputs": "Run finished",
        "runAfter": {}
      }
    },
    "runAfter": { "Try_Scope": ["Succeeded", "Failed", "Skipped", "TimedOut"], "Catch_Scope": ["Succeeded", "Failed", "Skipped", "TimedOut"] }
  }
}

Two things make this correct and non-obvious. First, the Finally_Scope runs after both the Try and the Catch with all four statuses: name only the Catch and the Finally is skipped on the happy path (because the Catch was skipped when the Try succeeded) — naming both guarantees it always runs. This single bug is why “my cleanup never runs when everything works” is a recurring support question. Second, the catch uses result('Try_Scope') to read what happened — covered next.

Reading the failure inside a catch block

A catch that just says “something failed” is barely better than no catch; the point is to react to what failed.

result() — the failure inspector

result('ScopeName') returns an array, one entry per action in that scope, each with name, status, code, error, and the action’s outputs/inputs. Filter it to the failed ones:

"Filter_Failures": {
  "type": "Query",
  "inputs": {
    "from": "@result('Try_Scope')",
    "where": "@equals(item()?['status'], 'Failed')"
  },
  "runAfter": {}
}

The fields you actually read in each result() element:

Field Example value Use in a catch
name "Charge_Payment" Which action failed
status "Failed" Filter to failures
code "BadRequest" / "ActionFailed" Categorise the failure
error.code "InvalidCard" Map to a business decision
error.message "card declined" Human-readable for alerts/logs
outputs.statusCode 402 The HTTP status the action saw
outputs.body {...} The downstream’s error payload

Common expressions for catch blocks

Where result() discovers the failure across a scope, @actions('ActionName') drills into one known action’s record. The expressions you will reach for:

Expression Returns Typical use
result('Try_Scope') Array of all child action results Inspect the whole scope
first(result('Try_Scope')) First child result object Quick single-action scopes
@actions('Name')?['status'] "Failed" etc. Branch on a known action’s status
@actions('Name')?['outputs']?['statusCode'] HTTP status (e.g. 429) Decide retry vs dead-letter
@actions('Name')?['error']?['message'] Error message string Put in the alert / log
@{coalesce(...,'unknown')} First non-null Defensive against missing fields

A robust catch extracting the first failure’s code and message into a clean object for logging or alerting:

"Build_Error_Record": {
  "type": "Compose",
  "inputs": {
    "failedAction": "@{first(body('Filter_Failures'))?['name']}",
    "statusCode": "@{first(body('Filter_Failures'))?['outputs']?['statusCode']}",
    "errorCode": "@{first(body('Filter_Failures'))?['error']?['code']}",
    "message": "@{coalesce(first(body('Filter_Failures'))?['error']?['message'], 'unknown error')}",
    "orderId": "@{triggerBody()?['orderId']}"
  },
  "runAfter": { "Filter_Failures": ["Succeeded"] }
}

Terminate, dead-letter and compensation

Catching a failure is half the job; the other half is doing the right thing. Three responses — terminate, dead-letter, compensate — and the right one depends on the failure.

Terminate — end the run with a truthful status

The Terminate action stops the run immediately with a status you choose. Without it, a catch that “handles” a failure makes the overall run show Succeeded — lying to your monitoring; use it to mark a caught-but-fatal failure as Failed so alerts fire and run history is honest.

runStatus Effect on the run Use when
Failed Run shown as Failed; can carry a runError code+message A fatal error you caught and recorded — most catch blocks
Cancelled Run shown as Cancelled A deliberate, non-error stop (e.g. business rule said “skip”)
Succeeded Run shown as Succeeded The catch fully recovered; the failure was expected & handled
"Terminate_As_Failed": {
  "type": "Terminate",
  "inputs": {
    "runStatus": "Failed",
    "runError": { "code": "PaymentDeclined", "message": "@{body('Build_Error_Record')?['message']}" }
  },
  "runAfter": { "Build_Error_Record": ["Succeeded"] }
}

Dead-letter — never lose a poison message

A poison message can never succeed (malformed, references a deleted entity, violates a constraint). Retrying it forever wastes runs; failing silently loses it; the right move is to route it aside for a human or a separate process while the main flow continues.

Dead-letter target How to send Best when
Service Bus DLQ The queue/subscription’s built-in dead-letter sub-queue, or a “Dead-letter the message” action You already consume from Service Bus; native DLQ semantics
Storage Queue / Table “Insert entity” / “Put message” action in the catch Lightweight, cheap, queryable later
A separate “errors” Service Bus queue “Send message” in the catch You want a dedicated reprocessing pipeline
Blob (JSON file) “Create blob” with the payload + error Audit trail of every poison payload

A catch that writes the failed payload plus error to a storage table for later inspection:

"Dead_Letter_To_Table": {
  "type": "ApiConnection",
  "inputs": {
    "host": { "connection": { "name": "@parameters('$connections')['azuretables']['connectionId']" } },
    "method": "post",
    "path": "/Tables/deadletters/entities",
    "body": {
      "PartitionKey": "@{formatDateTime(utcNow(),'yyyy-MM-dd')}",
      "RowKey": "@{guid()}",
      "OrderId": "@{triggerBody()?['orderId']}",
      "Error": "@{string(body('Build_Error_Record'))}",
      "Payload": "@{string(triggerBody())}"
    }
  },
  "runAfter": { "Build_Error_Record": ["Succeeded"] }
}

Compensation — undo a half-done transaction

If your try Scope did a multi-step write and failed halfway, the catch should compensate — undo what succeeded so the world is consistent. Example: Reserve_Inventory succeeded but Charge_Payment failed → the catch runs a Release_Inventory HTTP action, deciding what to undo by reading result('Try_Scope') for the steps that reached Succeeded.

Which response to use:

If the failure is… Response Example
Transient and now resolved Retry already handled it 503 that recovered
Fatal but the message could be reprocessed Dead-letter + Terminate Failed Downstream down for hours
Fatal and the data is bad Dead-letter (no reprocess) + Terminate Malformed payload
Half-completed multi-step write Compensate + Terminate Reserved but not charged
Expected business rejection Branch + Terminate Cancelled “Customer on hold”

Architecture at a glance

The diagram traces a single message through a resilient order-processing workflow, left to right, and shows where each primitive bites. A message arrives on the Service Bus trigger (a queue the workflow peek-locks from) and enters the Try Scope, where two side-effecting actions run in sequence: Reserve Inventory (HTTP with an exponential retry) and Charge Payment (HTTP carrying an idempotency key so a retry can’t double-charge). Both succeed → the message completes and the lock releases (the happy path); either exhausts its retries on a real outage → the Try Scope rolls up to Failed.

That failure triggers the Catch Scope (runAfter: ["Failed","TimedOut"], so it runs only on failure). Inside, the workflow reads result('Try_Scope') to find which action broke, compensates if inventory was reserved but payment failed, writes the poison payload to the Service Bus dead-letter queue so nothing is lost, and ends with Terminate (Failed) so run history is truthful and an Azure Monitor alert fires to a human. The five numbered badges mark where the design earns its keep; the legend narrates each as symptom · confirm · the primitive that handles it. The shape is the lesson: a happy path guarded by a Scope, a catch on its failure, and a dead-letter that turns a bad message into a queue entry, not a lost event.

Resilient Logic Apps order workflow: a Service Bus queue trigger feeds a Try Scope of Reserve Inventory and Charge Payment HTTP actions (exponential retry, idempotency key); on the Try Scope failing or timing out, a Catch Scope wired via runAfter reads result() to find the failed action, compensates by releasing inventory, dead-letters the poison message to a Service Bus dead-letter queue, and runs Terminate Failed which fires an Azure Monitor alert to an on-call engineer, with five numbered badges on the retry, idempotency, catch, dead-letter and terminate points

Real-world scenario

Tindra Logistics runs a shipment-booking workflow on a Consumption Logic App in Central India: a Service Bus queue receives a booking, the workflow reserves capacity with a carrier API, charges the customer via a billing API, and writes the booking to Cosmos DB. Three engineers, ~12,000 bookings/day, ~₹9,000/month — “working” for four months.

The incident surfaced as a finance complaint, not an alert — which is the whole problem. The billing team found 47 customers charged twice in one week; no failed runs were flagged, no pages, the run history was healthy green. The integration lead’s first instinct — “our code is fine, it’s the billing API” — was wrong in the usual way.

The breakthrough came from reading two runs side by side. On the duplicated orders, Charge_Payment showed a first attempt that timed out (the billing API was slow during a deployment) then a retry that succeeded — the default exponential retry policy doing exactly what it was designed to do. But the API had actually processed the first charge; only the response was lost, so the retry charged again, and the workflow passed no idempotency key. Worse, there was no catch block at all — so on the rarer runs where the API was down past all retries, the run failed silently, the message had already been consumed off the queue, and the booking was lost. They had been losing roughly one booking a day and never knew.

The fix came in three changes. First, an idempotency key: the charge call now passes Idempotency-Key: @{triggerBody()?['bookingId']} and the billing team enabled dedupe — ending the double-charges. Second, the reserve/charge/write sequence was wrapped in a Try Scope with a Catch Scope (runAfter: ["Failed","TimedOut"]) that reads result('Try_Scope'), compensates by releasing the carrier reservation if the charge failed, writes the failed payload to a Service Bus dead-letter queue, and runs Terminate (Failed). Third, an Azure Monitor alert on the failed-run metric now pages on-call, and the DLQ is drained by a small reprocessing workflow each morning. The next month: zero double-charges, zero silent losses, four poison messages caught and reprocessed by hand — and the bill went up ~₹400 for the DLQ and catch actions, cheap insurance against a finance incident.

The incident as a timeline — the order of discovery is the lesson:

When Symptom What they thought What was actually true
Week 0 47 double-charges “Billing API bug” Their retry + no idempotency key
Day 1 Green run history “Nothing’s failing” Silent failures had no catch to surface them
Day 2 Two runs compared “Random” Timeout → retry → double side effect
Day 3 Root cause Non-idempotent retry + no catch/dead-letter
Day 4 Idempotency key added Double-charges stop immediately
Week 2 Try/Catch + DLQ + alert Silent loss ends; failures now visible
Month 2 Stable 0 dupes, 0 lost, 4 caught & reprocessed

Advantages and disadvantages

The Scope-plus-runAfter model gives you real try/catch/finally with no code — but it has sharp edges. Weigh it honestly:

Advantages Disadvantages
Default retry policies self-heal most transient faults with zero config That same default silently retries non-idempotent calls → duplicate side effects
runAfter gives genuine try/catch/finally with a tick-box UI — no code The “skipped” status makes finally blocks subtly wrong if you don’t name both try and catch
result() lets a catch read exactly which action failed and why The expression syntax is fiddly; missing ?['...'] null-safety throws inside the catch
Scopes group a transaction so you guard it as a unit, not per-action Compensation is manual — Logic Apps has no automatic rollback; you write the undo
Terminate makes run status truthful so alerts/monitoring reflect reality Forgetting Terminate makes a “handled” failure show as Succeeded — a silent lie
Dead-letter patterns turn a lost message into a recoverable queue entry You must build dead-lettering yourself; nothing is automatic for HTTP/most connectors
Same JSON in designer, az and Bicep — fully reproducible Deep nesting (>4 scopes) gets unreadable fast and hits the 8-level cap

The model is right for virtually every production workflow — the alternative is a happy-path-only integration that loses data. It shines for queue-driven workflows (dead-letter is natural) and multi-step writes (compensation matters), and bites teams that ship the designer default without opening the retry policy or adding a catch. Every disadvantage is manageable — but only if you know it exists, which is the point of this article.

Hands-on lab

You will build a resilient workflow end to end — a Consumption Logic App with a manual HTTP trigger, a Try Scope calling a deliberately-failing endpoint, a Catch Scope wired via runAfter that reads the failure with result() and Terminates as Failed, and a Finally Scope — then reproduce a failure, watch the catch fire, and confirm the status is truthful. We do it three ways — portal, az CLI, Bicep — free-tier-friendly (a few paise), deleting everything at the end.

Prerequisites: an Azure subscription, Cloud Shell (Bash) open, and az logged in. No code, no local tools.

Part A — Portal designer

Step 1 — Resource group.

RG=rg-lapp-lab
LOC=centralindia
az group create -n $RG -l $LOC -o table

Expected: a table row with provisioningState: Succeeded.

Step 2 — Create an empty Consumption Logic App in the portal. Portal → Create a resourceLogic AppConsumption → resource group rg-lapp-lab, name lapp-resilient-lab, region Central India → Review + createCreate. When it deploys, Go to resource → it opens the Logic Apps Designer.

Step 3 — Add the trigger. In the designer, choose When a HTTP request is received as the trigger (search “Request”). Leave the JSON schema empty for now. Save — a POST URL is generated; copy it, you will call it in Step 9.

Step 4 — Add the Try Scope. Click + New step → search Scope → add it → rename it Try. Inside the Scope, Add an actionHTTP → method GET, URI https://httpbin.org/status/500 — a public endpoint that returns HTTP 500 every time, a guaranteed (and retriable) failure. Open the action’s Settings → set Retry Policy to Exponential, Count = 2, Interval = PT5S so the lab fails fast after 2 retries rather than the default 4.

Step 5 — Add the Catch Scope. Below the Try Scope, + New stepScope → rename it Catch. Inside it add a Compose action named Capture_Error with input expression @result('Try'). Add a second action inside the catch: Terminate (search “Terminate”) → set Status = Failed, Code = TryFailed, Message = @{first(result('Try'))?['error']?['message']}.

Step 6 — Wire the catch’s runAfter (the key step). On the Catch Scope, click Configure run after → it lists Try. Untick “is successful”, tick “has failed” and “has timed out”Done. The connector between Try and Catch now shows the failed-path styling.

Step 7 — Add a Finally Scope. + New stepScope → rename Finally. Inside, add a Compose named Log_Done with input Run finished. On the Finally Scope → Configure run after → tick all four boxes for both Try and Catch. Done.

Step 8 — Save. Click Save; the designer validates the runAfter graph. If it flags an unreachable action, re-check Steps 6–7.

Step 9 — Run it and watch the catch fire. Trigger the workflow by POSTing to the URL from Step 3:

TRIGGER_URL='<paste the POST URL from Step 3>'
curl -s -X POST "$TRIGGER_URL" -H "Content-Type: application/json" -d '{"orderId":"A-1001"}' -i | head -5

Then in the portal open OverviewRun history → click the latest run. Expected:

Step 10 — Prove the happy path. Edit the Try HTTP action’s URI to https://httpbin.org/status/200 → Save → POST again. Expected: Try Succeeded, Catch Skipped, Finally Succeeded, run Succeeded — both branches confirmed.

Part B — The same workflow via az CLI

Deploy the entire workflow definition from a JSON file with az logic workflow create. First write the definition:

cat > /tmp/resilient.definition.json <<'JSON'
{
  "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
  "contentVersion": "1.0.0.0",
  "triggers": {
    "manual": {
      "type": "Request",
      "kind": "Http",
      "inputs": { "schema": {} }
    }
  },
  "actions": {
    "Try": {
      "type": "Scope",
      "actions": {
        "Call_Failing": {
          "type": "Http",
          "inputs": {
            "method": "GET",
            "uri": "https://httpbin.org/status/500",
            "retryPolicy": { "type": "exponential", "count": 2, "interval": "PT5S" }
          },
          "runAfter": {}
        }
      },
      "runAfter": {}
    },
    "Catch": {
      "type": "Scope",
      "actions": {
        "Capture_Error": { "type": "Compose", "inputs": "@result('Try')", "runAfter": {} },
        "Terminate": {
          "type": "Terminate",
          "inputs": {
            "runStatus": "Failed",
            "runError": { "code": "TryFailed", "message": "@{first(result('Try'))?['error']?['message']}" }
          },
          "runAfter": { "Capture_Error": ["Succeeded"] }
        }
      },
      "runAfter": { "Try": ["Failed", "TimedOut"] }
    },
    "Finally": {
      "type": "Scope",
      "actions": { "Log_Done": { "type": "Compose", "inputs": "Run finished", "runAfter": {} } },
      "runAfter": { "Try": ["Succeeded", "Failed", "Skipped", "TimedOut"], "Catch": ["Succeeded", "Failed", "Skipped", "TimedOut"] }
    }
  },
  "outputs": {}
}
JSON

Deploy it (az logic is an extension, installed on first use):

az extension add --name logic --only-show-errors
az logic workflow create \
  --resource-group $RG \
  --name lapp-resilient-cli \
  --location $LOC \
  --definition /tmp/resilient.definition.json \
  -o table

Expected: a workflow resource with provisioningState: Succeeded. Get the callback (trigger) URL and invoke it:

# Get the POST URL for the manual trigger
CALLBACK=$(az rest --method post \
  --uri "$(az logic workflow show -g $RG -n lapp-resilient-cli --query id -o tsv)/triggers/manual/listCallbackUrl?api-version=2016-06-01" \
  --query value -o tsv)
curl -s -X POST "$CALLBACK" -H "Content-Type: application/json" -d '{"orderId":"A-2002"}' -i | head -5

Inspect the run result:

# Most recent run and its status (should be Failed, code TryFailed)
RUN=$(az logic workflow run list -g $RG --name lapp-resilient-cli --query "[0].name" -o tsv)
az logic workflow run show -g $RG --name lapp-resilient-cli --run-name "$RUN" \
  --query "{status:status, code:error.code, started:startTime}" -o json

Expected JSON: status: "Failed", code: "TryFailed" — the catch fired and Terminate set a truthful status, all from the CLI.

Part C — The same workflow as Bicep

For repeatable, reviewed deployments the definition lives inside a Microsoft.Logic/workflows resource — write resilient.bicep:

param location string = resourceGroup().location

resource workflow 'Microsoft.Logic/workflows@2019-05-01' = {
  name: 'lapp-resilient-bicep'
  location: location
  properties: {
    state: 'Enabled'
    definition: {
      '$schema': 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#'
      contentVersion: '1.0.0.0'
      triggers: {
        manual: { type: 'Request', kind: 'Http', inputs: { schema: {} } }
      }
      actions: {
        Try: {
          type: 'Scope'
          actions: {
            Call_Failing: {
              type: 'Http'
              inputs: {
                method: 'GET'
                uri: 'https://httpbin.org/status/500'
                retryPolicy: { type: 'exponential', count: 2, interval: 'PT5S' }
              }
              runAfter: {}
            }
          }
          runAfter: {}
        }
        Catch: {
          type: 'Scope'
          actions: {
            Capture_Error: { type: 'Compose', inputs: '@result(\'Try\')', runAfter: {} }
            Terminate: {
              type: 'Terminate'
              inputs: {
                runStatus: 'Failed'
                runError: { code: 'TryFailed', message: '@{first(result(\'Try\'))?[\'error\']?[\'message\']}' }
              }
              runAfter: { Capture_Error: ['Succeeded'] }
            }
          }
          runAfter: { Try: ['Failed', 'TimedOut'] }
        }
        Finally: {
          type: 'Scope'
          actions: { Log_Done: { type: 'Compose', inputs: 'Run finished', runAfter: {} } }
          runAfter: { Try: ['Succeeded', 'Failed', 'Skipped', 'TimedOut'], Catch: ['Succeeded', 'Failed', 'Skipped', 'TimedOut'] }
        }
      }
      outputs: {}
    }
  }
}

output workflowName string = workflow.name

Deploy and verify:

az deployment group create -g $RG --template-file resilient.bicep -o table
az logic workflow show -g $RG -n lapp-resilient-bicep --query "{name:name, state:state, provisioning:provisioningState}" -o json

Expected: state: Enabled, provisioning: Succeeded. The escaped single quotes (\') inside the Bicep expression strings are the one Bicep-specific gotcha when embedding Workflow Definition Language expressions.

Validation checklist

Step What you did What it proves
A-4/5 Try Scope calls a 500, Catch Scope after it Scopes group the transaction; catch is a separate scope
A-6 Configure run after → has failed/timed out runAfter is the entire try/catch mechanic
A-9 Run; Try Failed, Catch Succeeded, run Failed Catch fires only on failure; Terminate makes status truthful
A-10 Switch to 200; Catch Skipped, run Succeeded The happy path skips the catch correctly
B Same workflow from a JSON definition via az logic The JSON is the source of truth; fully scriptable
C Same workflow as reviewed Bicep Reproducible IaC; expression-escaping handled

Teardown

az group delete -n $RG --yes --no-wait

Cost note. Consumption Logic Apps bill per action execution; a dozen lab runs is well under ₹5, deleting the resource group stops all billing, and a generous monthly free grant of built-in action executions makes this lab effectively free.

Common mistakes & troubleshooting

The failure modes that bite real teams, as a scannable table first, then the detail on the ones that hurt most.

# Symptom Root cause Confirm (exact path) Fix
1 Duplicate side effects (double charge/email) Non-idempotent action retried after a lost-response timeout Run history: action shows attempt 1 TimedOut, attempt 2 Succeeded Pass an idempotency key; or set retry none + handle timeout
2 “I set retries to 10, still fails instantly” The failure is a 4xx (400/401/403/404) — not retriable Action outputs statusCode is 4xx Fix the request/creds; retries only help 408/429/5xx
3 Run shows Succeeded but the work failed Catch handled the error but no Terminate Overall status green despite a Failed action inside a scope Add Terminate (Failed) in the catch
4 Finally Scope never runs on the happy path Finally’s runAfter names only the Catch (which was Skipped) Finally shows Skipped when Try Succeeded runAfter both Try and Catch with all four statuses
5 Catch block throws / shows null Expression accesses a missing field without ?[...] Catch action error: “property ‘X’ cannot be selected” Null-safe everything: result('S')?[0]?['error']?['message']
6 Message lost on failure Trigger consumed the message, run failed, no dead-letter Failed run; the source queue message is gone Dead-letter the payload in the catch before Terminate
7 Catch never fires even though an action failed The failing action is outside the guarded Scope The Failed action isn’t a child of the Try Scope Move side-effecting actions inside the Try Scope
8 200 response treated as success, bad data flows Downstream returns 200 with an error body Action Succeeded but body has "error" Add a Condition on the body → Terminate if error
9 Retries hammer a struggling API for minutes Large count with fixed policy and no back-off Run history: many evenly-spaced attempts Use exponential; cap count; dead-letter long outages
10 “Action timed out” on a legitimately long call Per-action limit.timeout shorter than the operation Action status TimedOut before downstream replies Raise inputs.limit.timeout (up to P30D for long-running)
11 runAfter validation error on save An action’s runAfter names a non-existent or unreachable action Designer: “action X cannot be reached” Fix the predecessor name; ensure the path is reachable
12 Catch fires but result() is empty result() called on the wrong scope name (case-sensitive) Compose output is [] Match the exact Scope action name in result('Name')

Row 1 is the most damaging and easiest to miss: the operation actually succeeded (its response was lost to a timeout), so the default retry runs it again — you see attempt 1: TimedOut, attempt 2: Succeeded on a side-effecting action. An idempotency key is the only real fix; without dedupe, set retryPolicy: { "type": "none" } and resolve the timeout in a catch. And rows 3, 4 and 12 share one root cause — the Skipped status: a finally naming only the (skipped) catch never runs, a handled failure with no Terminate stays green, and result() on the wrong scope returns []. When in doubt, read every action’s status in run history, not just the overall verdict.

Best practices

Security notes

Error handling touches secrets, payloads and identities, so a few rules keep a resilient workflow from also being a leaky one:

Cost & sizing

What drives the bill differs sharply by hosting model; error handling adds a little:

Model Billing unit What error handling adds Free grant
Consumption Per action execution + standard-connector calls + trigger evaluations Each catch/compose/terminate is an extra action execution; retries count as executions Generous monthly free built-in action grant
Standard App Service / Workflow Standard plan (vCPU-hours), not per-action Catch actions are “free” within the plan; retries don’t add per-action cost Plan capacity is fixed; you pay for the plan

The cost levers specific to resilience:

Lever Cost effect Guidance
Retry count Each retry is a billable execution (Consumption) Cap count (2–4 is usually enough); huge counts cost and delay
exponential vs fixed Same per-execution cost; exponential reduces total attempts against a recovering dep Prefer exponential — fewer wasted attempts
Catch/compensation actions A few extra executions per failed run only Negligible — failures are the minority; cheap insurance
Dead-letter writes One storage/Service Bus operation per poison message Tiny; far cheaper than a lost-message incident
Polling triggers Each poll is a trigger evaluation (billable) Lengthen recurrence or use push triggers where possible
Standard plan sizing Fixed plan cost regardless of volume High-volume workflows are cheaper on Standard than per-action Consumption

Rough figures: a Consumption workflow at ~12,000 runs/day lands in the low thousands of rupees per month; adding try/catch/dead-letter (extra actions only on failures, plus a cheap DLQ) adds a few hundred — much less than one finance incident. Above roughly a few million action executions a month, model Standard (a flat plan) against Consumption; that decision is in Logic Apps Consumption vs Standard: Pricing, Networking and Stateful Workflows Compared.

Interview & exam questions

Q1. What is the default retry policy on a Logic Apps action, and which failures does it apply to? The default is exponential with 4 retries on most connector actions. It only fires on retriable failures — HTTP 408, 429, and 5xx, plus connection/timeout errors. Deterministic 4xx (400/401/403/404/409) fail immediately regardless of the count. (Relevant to AZ-204, AZ-305.)

Q2. How do you implement try/catch in a Logic App? There is no try/catch keyword. Put the guarded steps in a Scope, add a second (catch) Scope, and set its runAfter to ["Failed","TimedOut"] on the try Scope so it runs only when the try failed. A finally Scope uses runAfter with all four statuses on both the try and the catch.

Q3. Why might a finally Scope not run even though it’s configured? Because its runAfter names only the catch, which is Skipped on the happy path (the try succeeded), so a finally waiting on the catch’s Succeeded never fires. Fix it by depending on both the try and the catch with the full ["Succeeded","Failed","Skipped","TimedOut"] set.

Q4. How does a catch block know which action failed and why? Use result('ScopeName') — it returns an array of every child action’s result (name, status, code, error, outputs); filter to status == 'Failed'. Or use @actions('ActionName') to drill into a specific known action.

Q5. What’s the danger of retrying a non-idempotent operation, and how do you fix it? If the operation succeeds but its response is lost to a timeout, the retry repeats the side effect — a double charge or duplicate email. Pass an idempotency key so the downstream deduplicates; if it can’t, set the policy to none and handle the ambiguous timeout explicitly.

Q6. What does the Terminate action do and why does it matter? It ends the run immediately with a status you choose — Succeeded, Failed, or Cancelled — plus a custom code and message. Without it, a catch that handles an error leaves the overall run Succeeded, hiding the failure from monitoring; Terminate makes run status truthful.

Q7. A team set retries to 10 but the action still fails on the first attempt. Why? The failure is non-retriable — almost certainly a 4xx (e.g. 400 or 401). Logic Apps only retries 408/429/5xx and connection errors, so no count changes a deterministic 4xx; fix the request or credentials.

Q8. What is a dead-letter pattern and when do you use it? Routing a poison message (one that can never succeed) aside — to a Service Bus DLQ, a storage queue/table, or a blob — instead of retrying forever or losing it. Use it for fatal failures where the message should be preserved for manual review or reprocessing.

Q9. How does a Scope determine its status? It rolls up its children: all children Succeeded → Scope Succeeded; any unhandled child Failed or TimedOut → Scope Failed; cancelled mid-scope → Aborted/Cancelled. This roll-up is what lets you guard a whole transaction with one catch.

Q10. A downstream returns HTTP 200 with an error in the body. How should the workflow handle it? The retry policy won’t help — the runtime saw success. Add a Condition (or Switch) after the call to inspect the response body and Terminate (Failed) or route to the catch when it indicates failure.

Q11. What’s the difference between result() and @actions()? result('ScopeName') returns an array of all child results in a scope — use it to discover which action failed. @actions('ActionName') returns a single action’s record — use it to drill into a known one.

Q12. Where does error handling differ between Consumption and Standard? The primitives (retry, Scope, runAfter, Terminate) are identical. The differences are billing (Consumption charges per action execution; Standard is a flat plan) and networking — Standard runs single-tenant with VNet integration, which affects how you reach private dead-letter targets.

Quick check

  1. Which three retry types can you set on an action, and which is the default?
  2. What runAfter status set turns a Scope into a catch block?
  3. Why might a finally Scope be skipped on the happy path, and how do you fix it?
  4. Which HTTP status codes does Logic Apps retry, and which does it never retry?
  5. What does Terminate (runStatus: Failed) fix that a catch block alone does not?

Answers

  1. none, fixed, exponential (plus default). exponential (≈4 retries) is the default on most connector actions.
  2. ["Failed", "TimedOut"] (sometimes just ["Failed"]) on the guarded predecessor Scope.
  3. Because its runAfter names only the catch, which is Skipped when the try succeeds. Fix: depend on both the try and the catch with all four statuses ["Succeeded","Failed","Skipped","TimedOut"].
  4. Retries on 408, 429, 5xx and connection/timeout errors; never on deterministic 4xx (400/401/403/404/409).
  5. It makes the overall run status truthful — without it, a handled failure leaves the run Succeeded, so failure alerts never fire.

Glossary

Next steps

AzureLogic AppsError HandlingRetry PolicyScopesrunAfterResilienceIntegration
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading