Integration

Salesforce Integration Error Handling — Idempotency, Retries, Circuit Breakers and Failure Diagnosis

By Rishabh Panwar · 6 min read · Advanced

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.

StateBehaviour
ClosedNormal operation. Calls go through and consecutive failures are counted
OpenThe failure threshold was reached (for example, 5 in a row). Calls fail immediately without touching the network
Half-openAfter 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:

CauseDetail
Daily API allocation used upThe allocation is a rolling 24-hour window. Another process may be consuming the org’s quota
Too many long-running concurrent API requestsIn 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.

LimitSymptom
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.
PercentileMeaning
p50 (median)Half the calls were faster and half slower
p9595% of calls were faster than this
p9999% 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).

  1. Build monitoring first. Missing error visibility is often what allowed the original silent failure. Don’t rebuild the same blind spot.
  2. 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.
  3. Run both paths in parallel for a couple of weeks, with the new path publishing alongside the old one.
  4. Reconcile. Have the consumer compare what arrives on each path.
  5. 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.

Frequently asked questions

What does idempotent mean in a Salesforce integration?

An operation is idempotent when running it twice leaves the system in the same state as running it once. Duplicates appear when an operation isn't idempotent and the sender retries.

How do I prevent duplicate records from integration retries?

Upsert on an External ID field. If the same payload arrives twice, the second call matches the first record and updates it. Duplicate rules are a data-quality feature and slow down high-volume writes, so they aren't the right fix.

How do I build a circuit breaker in Apex?

Store the breaker's state and failure count in Platform Cache (org partition) or a custom setting, since static variables reset at the end of each transaction. Skip callouts while the breaker is open, and let one trial call through after a cooldown.

What causes REQUEST_LIMIT_EXCEEDED in Salesforce?

Two documented causes: the org has used its rolling 24-hour API allocation, or too many long-running API requests are in flight at once (25 concurrent requests lasting 20 seconds or longer in production). Check the /limits REST resource or API Usage in Setup first.

What does CalloutException: Read timed out mean?

The response didn't arrive within the callout's timeout. It describes the round trip from Salesforce's side, so it can happen even when the remote server reports fast processing times. Check whether a timeout is set and look at p99 latency instead of the average.

How do I safely replace a legacy Salesforce integration?

Add monitoring first, use the existing configuration as the specification, run the new path alongside the old one, reconcile the two, and switch off the old path only when volumes and content match.