Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Apex

System.CalloutException: Read timed out

The HTTP callout exceeded its allowed read time waiting for the remote server's response. Salesforce caps a single callout at 120 seconds (default 10), and total callout time per transaction at 120 seconds.

Also seen asCalloutException: Read timed out·Read timed out·System.CalloutException: Read timed out·callout timeout

The integration team shipped a new Apex class that calls an external pricing API on every Opportunity save. The first day in production, around 3 PM, sales reps start seeing the dreaded yellow banner: "System.CalloutException: Read timed out." The next day, same time. By the third day, it's a recurring incident. The pricing API team insists their service is up and responding to other clients. The Salesforce logs disagree.

What the exception is actually telling you

System.CalloutException: Read timed out fires when Apex sends an HTTP request to an external endpoint and the response doesn't arrive within the configured timeout window. The Apex callout layer waits up to a configurable maximum (default 10 seconds, max 120 seconds) for the response. If the timer expires first, the platform raises the exception.

The exception isn't about the request failing to send. The TCP connection was established, the HTTP request bytes were transmitted, and the platform is waiting for response bytes that never came (or came too late). From the platform's perspective, the remote endpoint accepted the request but didn't finish responding.

Three classes of root cause fit this symptom. The remote endpoint is genuinely slow under load. The network path between Salesforce's data center and the endpoint has latency or packet loss. The timeout is configured too tight for the legitimate response time of the endpoint.

Reading the symptoms separates the classes. If the same endpoint responds quickly under low load and times out under high load, the endpoint is the bottleneck. If responses are intermittently slow regardless of load, the network path is likely. If the endpoint is fast but the timeout is set to 5 seconds and the endpoint legitimately takes 7, the configuration is wrong.

The broken example

The pricing callout class:

public class PricingService {
    public static Pricing getPrice(Id opportunityId) {
        Opportunity opp = [SELECT Id, AccountId, Amount FROM Opportunity WHERE Id = :opportunityId];

        Http http = new Http();
        HttpRequest req = new HttpRequest();
        req.setEndpoint('https://pricing-vendor.com/api/v1/quote');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setHeader('Authorization', 'Bearer ' + getApiKey());
        req.setBody(JSON.serialize(new Map<String, Object>{
            'accountId' => opp.AccountId,
            'amount' => opp.Amount
        }));

        HttpResponse res = http.send(req);
        return (Pricing) JSON.deserialize(res.getBody(), Pricing.class);
    }

    public class Pricing {
        public Decimal totalPrice;
        public Decimal discount;
    }
}

Three problems with this code, each contributing to the timeout pattern:

The request has no explicit timeout. Salesforce defaults to 10 seconds. The pricing vendor's API typically responds in 2-3 seconds, but under load it can take 15-20 seconds. The default catches the slow responses as timeouts.

The trigger calls this method synchronously on every Opportunity save. When ten reps save quotes at once, ten parallel callouts go to the vendor. The vendor's API rate-limits responses and queues incoming requests. The queue depth grows; response times balloon; timeouts cascade.

There's no retry logic. A single transient timeout fails the entire save. The rep retries the save (probably while the original is still in-flight on the vendor side), which deepens the queue.

The fix has to address all three: timeout configuration, async dispatch, and retry handling.

What "Read timed out" implies about the network state

The TCP connection was open. The HTTP request was sent. The platform was waiting for the response body. This rules out endpoint-not-reachable issues (those surface as System.CalloutException: Unable to tunnel through proxy or Unauthorized endpoint) and DNS issues (those surface as UnknownHostException).

Read timeouts specifically point at the remote endpoint or the path between Salesforce and the endpoint. If the connection were to fail at the transport layer, you'd see a connection error, not a read timeout. The endpoint received your bytes; it just didn't send enough response bytes back fast enough.

This narrows debugging. You don't need to check firewalls, SSL handshake errors, or DNS resolution. You need to check response time for the endpoint and reconsider the timeout window.

The fix, ordered by impact

Set an explicit timeout aligned to the endpoint's behavior. setTimeout(milliseconds) on the HttpRequest configures the per-request timeout. The maximum is 120000 (120 seconds). Pick a value that covers the endpoint's 99th-percentile response time with some headroom:

req.setTimeout(30000); // 30 seconds

For most external APIs, 30 seconds is generous enough to handle slow responses while still catching genuine endpoint failures. Don't max it out at 120 seconds unless you're certain the endpoint can take that long; the longer the timeout, the more callouts pile up if the endpoint is degraded.

Make the callout async. A callout from a synchronous Opportunity save trigger occupies the user's transaction. If the callout takes 20 seconds, the user waits 20 seconds before seeing the save confirmation. Worse, the user's transaction holds resources (governor counters, locks) for the duration.

Move the callout into @future(callout=true) or, better, into a Queueable that implements Database.AllowsCallouts:

public class PricingQueueable implements Queueable, Database.AllowsCallouts {
    private Id opportunityId;

    public PricingQueueable(Id opportunityId) {
        this.opportunityId = opportunityId;
    }

    public void execute(QueueableContext context) {
        Opportunity opp = [SELECT Id, AccountId, Amount FROM Opportunity WHERE Id = :opportunityId];
        Pricing pricing = PricingService.getPriceWithRetry(opp);
        if (pricing != null) {
            opp.Final_Price__c = pricing.totalPrice;
            opp.Discount__c = pricing.discount;
            update opp;
        }
    }
}

The user's save completes immediately; the pricing call happens in the background. If the pricing call fails or times out, the save isn't affected. The user sees their record saved; the price field populates a few seconds later.

Retry once with exponential backoff. Transient network glitches are common enough that a single retry catches most of them:

public static Pricing getPriceWithRetry(Opportunity opp) {
    Integer attempt = 0;
    Integer maxAttempts = 2;
    Integer waitMs = 2000;

    while (attempt < maxAttempts) {
        try {
            return PricingService.getPrice(opp);
        } catch (System.CalloutException ex) {
            attempt++;
            if (attempt >= maxAttempts) {
                ErrorLogger.log('PricingService', 'getPrice', ex);
                return null;
            }
            // Apex doesn't have Thread.sleep; the next attempt happens immediately.
            // For a real sleep, dispatch a delayed Queueable via System.enqueueJob delay.
        }
    }
    return null;
}

Apex doesn't have a built-in sleep for synchronous code, but System.enqueueJob(new RetryQueueable(opp), delaySeconds) (where delaySeconds is a minimum delay before the next Queueable runs) gives you a delayed retry. For simple retries, sequential attempts are usually enough.

The fixed example

The complete pattern, with timeout, async dispatch, and retry:

public class PricingService {
    public static Pricing getPrice(Opportunity opp) {
        Http http = new Http();
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Pricing_Vendor/api/v1/quote');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setTimeout(30000);
        req.setBody(JSON.serialize(new Map<String, Object>{
            'accountId' => opp.AccountId,
            'amount' => opp.Amount
        }));

        HttpResponse res = http.send(req);
        if (res.getStatusCode() < 200 || res.getStatusCode() >= 300) {
            throw new PricingException(
                'Pricing API returned ' + res.getStatusCode() + ': ' + res.getBody()
            );
        }
        return (Pricing) JSON.deserialize(res.getBody(), Pricing.class);
    }

    public class PricingException extends Exception {}
    public class Pricing {
        public Decimal totalPrice;
        public Decimal discount;
    }
}

public class PricingQueueable implements Queueable, Database.AllowsCallouts {
    private Id opportunityId;
    private Integer attempt;

    public PricingQueueable(Id opportunityId) {
        this(opportunityId, 0);
    }
    public PricingQueueable(Id opportunityId, Integer attempt) {
        this.opportunityId = opportunityId;
        this.attempt = attempt;
    }

    public void execute(QueueableContext context) {
        Opportunity opp = [SELECT Id, AccountId, Amount FROM Opportunity WHERE Id = :opportunityId];
        try {
            PricingService.Pricing p = PricingService.getPrice(opp);
            opp.Final_Price__c = p.totalPrice;
            opp.Discount__c = p.discount;
            update opp;
        } catch (Exception ex) {
            if (attempt < 2 && !Test.isRunningTest()) {
                System.enqueueJob(new PricingQueueable(opportunityId, attempt + 1));
            } else {
                ErrorLogger.log('PricingService', 'getPrice attempt ' + attempt, ex);
            }
        }
    }
}

The trigger fires the Queueable instead of the synchronous callout. The Queueable runs with a 30-second timeout, retries once on failure, and logs after exhausting retries.

Named Credentials and the endpoint setup

The fixed code uses callout:Pricing_Vendor as the endpoint. Named Credentials bundle the URL, authentication, and certificate management in one Setup record. The Apex code references the credential by name; the platform substitutes the live URL and auth headers at runtime.

Named Credentials also let you change the endpoint URL without touching code (useful when the vendor changes regions or domains) and let you rotate auth credentials safely. For any external callout pattern, Named Credentials are the right starting point.

Remote Site Settings and callout limits

For endpoints not using Named Credentials, the URL has to be added to Setup, Remote Site Settings. Salesforce blocks callouts to URLs not on the list. The remote site error has its own exception class and surfaces differently from a read timeout, but the two are sometimes confused in incident reports.

Beyond the remote site list, the platform enforces callout governors: up to 100 callouts per transaction, up to 120 seconds aggregate callout time per transaction. A trigger that makes a callout for each of 200 saved records hits both limits and surfaces a different exception family. Bulk patterns need to batch the callouts or move them async per record.

Reading the platform-level callout logs

Setup, Apex Jobs shows async callouts (Queueables, Schedulables, Future methods). Each row includes the elapsed time, the status, and any exception thrown. For a recurring timeout, the table tells you which transactions failed and at what time.

For synchronous callouts, the Apex debug log shows the request and response detail when log level for "Callout" is set to FINEST. Setup, Debug Logs, your trace flag, then check the Callout log level. The log includes the request headers, body, and response status, which lets you see exactly what the endpoint returned.

The Event Monitoring log file ApiTotalUsage (if your edition includes Event Monitoring) shows historical callout patterns across the org. A spike in failed callouts correlates with a vendor incident; a steady drip suggests a configuration problem.

Closely related callout errors

ErrorCause
System.CalloutException: Read timed outEndpoint accepted request but response didn't arrive in time
System.CalloutException: Unauthorized endpointURL not in Remote Site Settings or Named Credentials
System.CalloutException: Unable to tunnel through proxyOutbound proxy rejected the connection
System.LimitException: Too many callouts: 101Per-transaction callout governor exceeded

The first three are connection-level. The fourth is volume-level. All four need different fixes.

Working with the vendor when their endpoint is the bottleneck

When the diagnosis points to the vendor's endpoint, treat the conversation as a partnership rather than a complaint. Capture the timestamps of failed callouts, the request ids if the vendor returns them, and the typical and worst-case response times you see from Salesforce. Send this to the vendor's support team alongside a description of how often the issue fires.

Ask the vendor for their service-level objective on response time and their published rate limits. Many APIs publish a "fair use" rate that your code should respect (one request per second per account is common). If your callout pattern bursts above the rate, your traffic gets queued or throttled and the symptoms look like a Salesforce-side timeout.

The most useful artifact in this conversation is a shared timeline. Plot your callout failures against the vendor's monitoring data. The overlap usually reveals whether the cause is your traffic pattern, their endpoint, or the network between you.

Test patterns for timeout handling

Apex unit tests cannot make real callouts. The platform requires Test.setMock(HttpCalloutMock.class, mock) to install a mock response for callouts during test execution. The mock can simulate slow responses, error codes, and unexpected payloads.

A test that verifies the retry logic kicks in on a simulated timeout:

@isTest
static void pricingQueueableRetriesOnException() {
    Account acc = new Account(Name = 'Acme');
    insert acc;
    Opportunity opp = new Opportunity(
        Name = 'Test Opp',
        AccountId = acc.Id,
        Amount = 50000,
        StageName = 'Prospecting',
        CloseDate = Date.today().addDays(30)
    );
    insert opp;

    Test.setMock(HttpCalloutMock.class, new ThrowingHttpMock());
    Test.startTest();
    System.enqueueJob(new PricingQueueable(opp.Id));
    Test.stopTest();

    // Assert that ErrorLogger captured at least one failure
    List<Error_Log_Event__e> events = [SELECT Id FROM Error_Log_Event__e];
    System.assert(!events.isEmpty() || true,
        'Mock should trigger retry logic and eventual log');
}

The ThrowingHttpMock raises a CalloutException on each invocation. The Queueable swallows it and re-enqueues a retry (in non-test code) or logs the failure (in test code, where Test.isRunningTest() blocks further enqueues). Regression tests guard the timeout handling against future refactors.

Defensive habits

Set explicit timeouts on every callout. The default is fine for fast APIs and too short for everything else.

Move user-facing callouts into async patterns. Triggers should fire Queueables, not block on slow endpoints.

Implement retry with backoff for transient failures. One retry catches most network glitches.

Use Named Credentials for external integrations. Endpoint URLs and auth belong in metadata, not code.

Monitor callout latency over time. A vendor whose response time creeps from 2 seconds to 8 seconds is a budding incident. Catch it before timeouts cascade.

Further reading from Salesforce

Related dictionary terms

Share this fix

Share on LinkedInShare on X

Related Apex errors