Salesforce saved the order. The response got lost on the way back. The middleware saw a timeout, sent the order again, and the customer was billed twice. Most integration incidents look like this: the happy path worked fine in testing, and the trouble showed up weeks later as duplicates, a retry storm that used up the API allocation, or an endpoint that slowed down for just 1% of calls. Good Salesforce integration error handling is mostly about expecting those failures. The sections below cover the patterns that prevent them, how to diagnose them when they happen, and how to retire an old integration without causing a new outage.
Event-based integrations have their own recovery tools, which Platform Events reliability covers. If your integration runs through Flow, the Flow fault path guide shows the declarative version of the same ideas.
Idempotency: the foundation
An operation is idempotent when doing it twice leaves the same state as doing it once. Get the direction of that word right: duplicates happen because an operation isn’t idempotent.
The classic cause of duplicates
Salesforce saves the record, but the response never makes it back to the sender, perhaps because of a network drop or a timeout on the client. The sender can’t tell that apart from a failure, so it sends the request again. If the operation was an insert, you now have two records.
How to make writes idempotent
- Upsert on an External ID. Send the same payload twice and you still end up with one record, because the second call matches the first and updates it.
- Use an idempotency key. The sender attaches a unique request ID and reuses it on every retry. The receiver records the IDs it has handled and skips repeats.
- Leave duplicate rules out of it. Duplicate rules exist for data quality. They slow high-volume writes and can block records that should have saved.
Retry, backoff and dead letters
Retrying immediately feels helpful, but when a system is already struggling it adds load at the worst possible moment. Wait a little longer before each attempt, and stop after a fixed number of tries.
Messages that still fail after the last retry need somewhere to go. A dead-letter queue holds them so a person can look at them later, which beats dropping them silently.
Also watch for the retry spiral. Calls fail, the sender retries, the retries use up more API calls, and even more calls fail. Left alone, it keeps feeding itself.
Circuit breaker
A circuit breaker stops calling an endpoint that is clearly down, which protects both systems and gives the remote side room to recover.
| State | Behaviour |
|---|---|
| Closed | Normal operation. Calls go through and consecutive failures are counted |
| Open | The failure threshold was reached (for example, 5 in a row). Calls fail immediately without touching the network |
| Half-open | After a cooldown, one trial call is allowed. Success closes the breaker; failure opens it again and restarts the cooldown |
The half-open state is the important one. It lets the integration recover without anyone intervening.
Building one in Apex
Apex has no built-in circuit breaker, so you keep the state yourself. Store it somewhere that survives between transactions, such as Platform Cache (org partition) or a custom setting. Static variables reset when the transaction ends, and the whole point is to remember failures across transactions.
public with sharing class PartnerBreaker {
private static final String KEY = 'local.Integrations.partnerBreaker';
private static final Integer THRESHOLD = 5;
private static final Integer COOLDOWN_SECONDS = 300;
public class State {
public Integer failures = 0;
public Datetime openedAt;
}
public static Boolean allowCall() {
State s = load();
if (s.openedAt == null) {
return true; // closed
}
if (Datetime.now() >= s.openedAt.addSeconds(COOLDOWN_SECONDS)) {
// half-open: re-arm the cooldown so only this one trial call goes through
s.openedAt = Datetime.now();
Cache.Org.put(KEY, s);
return true;
}
return false; // open
}
public static void recordSuccess() {
Cache.Org.put(KEY, new State());
}
public static void recordFailure() {
State s = load();
s.failures++;
if (s.failures >= THRESHOLD) {
s.openedAt = Datetime.now(); // open, or re-open after a failed trial
}
Cache.Org.put(KEY, s);
}
private static State load() {
State s = (State) Cache.Org.get(KEY);
return s == null ? new State() : s;
}
}
Before each callout, check allowCall(). If it returns false, log the skip and move on. Platform Cache entries can be evicted, so treat the breaker as a best-effort safeguard; an eviction simply resets it to closed.
Diagnosing common failures
REQUEST_LIMIT_EXCEEDED
Salesforce documents two causes:
| Cause | Detail |
|---|---|
| Daily API allocation used up | The allocation is a rolling 24-hour window. Another process may be consuming the org’s quota |
| Too many long-running concurrent API requests | In production, 25 requests running for 20 seconds or longer at the same time. Shorter requests don’t count toward this |
Start with the /limits REST resource or Setup → API Usage. Within a minute you will know whether the daily allocation is actually gone. Also check whether the integration’s own traffic has grown, and look for the retry spiral described above.
A different limit: concurrent long-running Apex
Long-running synchronous Apex has its own concurrency limit, and Salesforce documents it separately from REQUEST_LIMIT_EXCEEDED.
| Limit | Symptom |
|---|---|
| 10 concurrent synchronous Apex transactions running longer than 5 seconds (async Apex doesn’t count) | New requests are denied. Event Monitoring logs record ConcurrentLongRunningApexLimit, and Real-Time Event Monitoring publishes ConcurLongRunApexErrEvent |
CPU time and elapsed time are different
The 10,000 ms synchronous CPU limit doesn’t include time spent waiting on the database or on callouts. A DML-heavy upsert can take 30 seconds of wall-clock time while using 4 seconds of CPU. A slow transaction isn’t necessarily a CPU problem. The Apex governor limits guide lists what each limit measures.
CalloutException: Read timed out
This error means the response didn’t arrive before the timeout. It describes the full round trip as seen from Salesforce. The remote team’s dashboards measure server processing time, so both sides can be telling the truth: queueing, network hops and TLS negotiation all sit between the two measurements.
What to check:
- Is a timeout set? Without one, the callout uses the 10-second default.
- Ask for percentiles. Averages hide the slow tail.
| Percentile | Meaning |
|---|---|
| p50 (median) | Half the calls were faster and half slower |
| p95 | 95% of calls were faster than this |
| p99 | 99% of calls were faster; 1% were slower |
Suppose the median stays flat while p99 doubles to 12 seconds. One call in a hundred now takes longer than the 10-second default timeout. Across 50,000 callouts that is 500 failures, which is what “intermittent” timeouts usually turn out to be.
Fixes
- In Batch Apex, catch failures per record, log them and continue. One bad callout shouldn’t fail the whole batch.
- Ask for a delta or bulk endpoint. One call that returns all changes beats 50,000 individual lookups.
Retiring a legacy integration
When nobody fully knows what an old integration does, run old and new side by side and cut over gradually (often called the strangler approach).
- Build monitoring first. Missing error visibility is often what allowed the original silent failure. Don’t rebuild the same blind spot.
- Use the existing configuration as the specification. An Outbound Message setup already shows when it fires (the workflow or flow criteria) and what it sends (the selected fields). That is documentation you already have.
- Run both paths in parallel for a couple of weeks, with the new path publishing alongside the old one.
- Reconcile. Have the consumer compare what arrives on each path.
- Switch off the old path only when volumes and content match. A quiet period is the final step of the plan.
Questions to ask before designing the replacement:
- What does the receiving endpoint actually do with each message?
- Can the receiver handle the same message twice? Outbound Messages have retried for up to 24 hours all along, so it probably can. Confirm it anyway, because the new design can also deliver twice.
- How was the last outage noticed? The answer tells you what monitoring exists today.
Putting it into words
For resilience questions in a review or an interview, name the failure first (a lost response, a retry storm, a slow tail), then the pattern that addresses it. Tying duplicates to a lost response and fixing them with an External ID upsert is the answer interviewers most often look for.