A production integration landscape with dozens of APIs, multiple teams, and downstream consumers who need to automate error recovery cannot rely on scattered try/catch blocks and ad-hoc error shapes. The question is not whether your integration will encounter errors. It is whether it will survive them, tell you exactly what went wrong, and give your consumers a response they can act on.
Why default error handling is not enough
Out of the box, MuleSoft propagates errors up the flow and returns a generic HTTP 500 response. In a proof of concept, that is acceptable. In production, it is a liability. A production-grade MuleSoft error handling framework does four things consistently: classifies errors using MuleSoft's namespaced error type system, returns structured predictable payloads consumers can parse, logs everything needed to diagnose the problem fast, and propagates context cleanly across API layers.
Without a shared framework, each team implements their own version. The result: inconsistent HTTP status codes, incompatible error payload shapes, and debugging sessions that start with 'which API actually failed?'
Understanding the MuleSoft error object
When a flow fails, the Mule runtime constructs a rich error object. The top-level error exposes: errorType (namespace and identifier, e.g. HTTP:UNAUTHORIZED), errorMessage, description, detailedDescription, cause, failingComponent, and childErrors.
Critical: Java 8 vs Java 17 access paths. If you are running Mule 4.6 or higher with Java 17, the error object access paths changed. This breaks existing error transformations on migration without DataWeave updates.
| Mule 4.4 / Java 8 | Mule 4.6 / Java 17 |
|---|---|
| error.muleMessage.payload | error.errorMessage.payload |
| error.muleMessage | error.errorMessage |
| error.errors | error.childErrors |
| error.errorType.asString | error.errorType.namespace ++ ":" ++ error.errorType.identifier |
Step 1: Establish your error type taxonomy
Before writing a single line of error handler XML, define the error types your organisation will use. This taxonomy becomes the contract between APIs and consumers. MuleSoft namespaces all error types. Use APP:* for application-defined errors.
| Error Type | HTTP Status | When to Use |
|---|---|---|
| APP:BAD_REQUEST | 400 | Malformed request or failed business validation |
| APP:UNAUTHORIZED | 401 | Missing or invalid authentication credentials |
| APP:FORBIDDEN | 403 | Authenticated but not authorised |
| APP:NOT_FOUND | 404 | Requested resource does not exist |
| APP:TIMEOUT | 408/504 | Operation exceeded allowed time threshold |
| APP:SERVICE_UNAVAILABLE | 503 | Downstream dependency temporarily unreachable |
| APP:INTERNAL_SERVER_ERROR | 500 | Unhandled runtime failure |
Step 2: Build the shared error handler as a Mule plugin
The key architectural decision: the error handler lives in its own Mule project, packaged as a mule-plugin. This makes it an importable module, not a copy-pasted XML file. When you fix a bug or add an error type, you release a new version and all consuming APIs pick it up through a dependency upgrade.
The handler uses a series of on-error-continue blocks, one per error type. Each block sets the HTTP status code and delegates to a shared sub-flow that assembles the final error payload.
<error-handler name="global-error-handler">
<!-- 400 Bad Request -->
<on-error-continue
when="#[(error.errorType.identifier == 'BAD_REQUEST') or
(error.errorType.identifier == 'VALIDATION')]"
enableNotifications="false" logException="false">
<set-variable variableName="httpStatus" value="400"/>
<set-variable variableName="errorMsg" value="Bad Request"/>
<flow-ref name="error-handler:set-payload-subflow"/>
</on-error-continue>
<!-- 401 Unauthorized -->
<on-error-continue
when="#[error.errorType.identifier == 'UNAUTHORIZED']"
enableNotifications="false" logException="false">
<set-variable variableName="httpStatus" value="401"/>
<set-variable variableName="errorMsg" value="Unauthorized"/>
<flow-ref name="error-handler:set-payload-subflow"/>
</on-error-continue>
<!-- 503 Service Unavailable / Connectivity -->
<on-error-continue
when="#[(error.errorType.identifier == 'SERVICE_UNAVAILABLE') or
(error.errorType.identifier == 'CONNECTIVITY')]"
enableNotifications="false" logException="false">
<set-variable variableName="httpStatus" value="503"/>
<set-variable variableName="errorMsg" value="Service Unavailable"/>
<flow-ref name="error-handler:set-payload-subflow"/>
</on-error-continue>
<!-- Catch-all: 500 -->
<on-error-continue type="ANY"
enableNotifications="false" logException="false">
<set-variable variableName="httpStatus" value="500"/>
<set-variable variableName="errorMsg" value="Internal Server Error"/>
<flow-ref name="error-handler:set-payload-subflow"/>
</on-error-continue>
</error-handler>Step 3: The error payload sub-flow
The sub-flow assembles a normalised JSON error response. Notice the fallback chain for description: it walks the error object in priority order to find the most useful message available, handling both Java 8 and Java 17 runtime paths.
<sub-flow name="error-handler:set-payload-subflow">
<ee:transform>
<ee:variables>
<ee:set-variable variableName="errorPayload"><![CDATA[
%dw 2.0
output application/json
---
{
correlationId : correlationId,
timestamp : now() as String { format: "yyyy-MM-dd'T'HH:mm:ssZ" },
status : vars.httpStatus,
reasonPhrase : vars.errorMsg,
description :
if (!isEmpty(vars.errorDesc default "")) vars.errorDesc
else if (!isEmpty(error.errorMessage.payload default ""))
(error.errorMessage.payload.description default error.errorMessage.payload)
else (error.detailedDescription default error.description default vars.errorMsg),
errorType : (error.errorType.namespace ++ ":" ++ error.errorType.identifier),
path : (attributes.requestPath default null)
}
]]></ee:set-variable>
</ee:variables>
</ee:transform>
<flow-ref name="api-error-handler-flow"/>
</sub-flow>The api-error-handler-flow stub is intentional. It gives each API the hook to override or extend the final response without modifying the shared handler.
Step 4: Raise errors the right way
Within your API flows, raise errors using the Raise Error component. Set the type to one of the agreed APP:* types. Set the description to a message your consumers can act on. For downstream HTTP errors, use error type mapping on the HTTP Request component to re-classify connector-specific types before they reach the handler.
<!-- Business validation failure -->
<raise-error
type="APP:BAD_REQUEST"
description="The field 'orderId' is required and was not provided."/>
<!-- Map downstream HTTP errors to your taxonomy -->
<http:request method="GET" path="/orders/{id}">
<error-mappings>
<error-mapping sourceType="HTTP:NOT_FOUND" targetType="APP:NOT_FOUND"/>
<error-mapping sourceType="HTTP:UNAUTHORIZED" targetType="APP:UNAUTHORIZED"/>
<error-mapping sourceType="HTTP:SERVICE_UNAVAILABLE" targetType="APP:SERVICE_UNAVAILABLE"/>
</error-mappings>
</http:request>Step 5: Retry logic for transient errors
Not every error is permanent. Use the Until Successful scope with exponential back-off for transient errors. Never retry on business errors (400, 401, 403, 404): they will not resolve themselves. Pair Until Successful with a Try scope that catches MULE:RETRY_EXHAUSTED and maps it to APP:SERVICE_UNAVAILABLE.
<try>
<until-successful maxRetries="3" millisBetweenRetries="2000">
<http:request config-ref="HTTP_Request_config"
method="POST" path="/downstream-service/process"/>
</until-successful>
<error-handler>
<on-error-propagate type="MULE:RETRY_EXHAUSTED">
<raise-error
type="APP:SERVICE_UNAVAILABLE"
description="Downstream service did not respond after 3 retries."/>
</on-error-propagate>
</error-handler>
</try>Step 6: Publish to Anypoint Exchange and version via parent POM
Publishing the error handler to Exchange turns it from a shared file into a versioned, discoverable organisational asset. Configure distributionManagement in the plugin pom.xml to target your organisation's Exchange repository, then run mvn clean deploy.
A parent POM centralises the version declaration. When the error handler version changes, you bump it in one file and all APIs pick it up. Each consuming API declares the dependency without a version; it is resolved from dependencyManagement in the parent.
<!-- Parent POM: bump the version here, all APIs update automatically -->
<properties>
<global-error-handler.version>1.2.0</global-error-handler.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>${orgId}</groupId>
<artifactId>global-error-handler</artifactId>
<version>${global-error-handler.version}</version>
<classifier>mule-plugin</classifier>
</dependency>
</dependencies>
</dependencyManagement>Step 7: Wire the handler in your API
With the dependency declared, import the plugin configuration and reference the handler in your main flow. Two lines of XML. The HTTP listener reads httpStatus and the error body from variables. The global error handler sets those variables. They never need to see each other's internals.
<!-- global-config.xml: import the shared error handler -->
<import doc:name="Import" file="global-error-handler.xml"/>
<!-- main flow: single line reference -->
<flow name="orders-exp-api-main">
<http:listener config-ref="httpListenerConfig" path="${http.api.path}">
<http:response statusCode="#[vars.httpStatus default 200]"/>
<http:error-response statusCode="#[vars.httpStatus default 500]">
<http:body>#[payload]</http:body>
</http:error-response>
</http:listener>
<apikit:router config-ref="orders-exp-api-config"/>
<error-handler ref="global-error-handler"/>
</flow>Step 8: Versioning strategy
The shared plugin follows semantic versioning. Changes that modify the error payload shape or remove error types are major version bumps. Everything else is a patch. When releasing a major version, maintain the previous version in Exchange for a defined migration window before deprecating.
Step 9: Test the handler with MUnit
The shared plugin must have its own MUnit test suite. Test each error type, confirm the HTTP status code, and verify the payload structure. Any change to the plugin should be covered by tests before publishing a new version.
<munit:test name="test-bad-request-returns-400"
description="APP:BAD_REQUEST maps to 400 with correct payload structure">
<munit:execution>
<raise-error type="APP:BAD_REQUEST" description="Test: orderId is required."/>
</munit:execution>
<munit:validation>
<munit-tools:assert-that
expression="#[vars.httpStatus]"
is="#[MunitTools::equalTo('400')]"/>
<munit-tools:assert-that
expression="#[vars.errorPayload.correlationId != null]"
is="#[MunitTools::equalTo(true)]"/>
</munit:validation>
</munit:test>What you have built
A MuleSoft error handling framework that is typed, consistent, versioned, and shared. Not a copy-pasted XML fragment in 40 repositories. A single artifact in Anypoint Exchange, with a parent POM managing the version across your entire API portfolio. When a bug is found, you fix it once. When a new error type is needed, you add it once. That is what a mature integration practice looks like.
This error handler is part of the Ampleshift accelerator library, one of the reasons our projects reach production with consistent error behaviour from day one.
