Skip to main content

Handling 'RESOURCE_TEMPORARILY_EXHAUSTED': Best Practices for Google Ads API Rate Limiting

 There is no quicker way to kill a high-performance marketing SaaS platform than ignoring gRPC error code 8. You are running a bulk keyword update for a tier-one enterprise client. The job runs smoothly for the first 2,000 operations, then suddenly crashes. Your logs are flooded with RESOURCE_TEMPORARILY_EXHAUSTED.

If your recovery strategy is simply "try again immediately," you are actively participating in a Denial of Service (DoS) attack against your own integration.

This error is not a hard quota limit (like the daily operation cap). It is a flow control mechanism. This article dissects why Google throws this error, how to architect a resilience layer using exponential backoff with jitter, and when to switch from standard mutations to the BatchJobService.

The Root Cause: gRPC Code 8 and System Overload

To fix the error, you must understand the architecture of the Google Ads API. It relies on gRPC, a high-performance RPC framework.

When you receive RESOURCE_TEMPORARILY_EXHAUSTED, it maps to HTTP status 429 (Too Many Requests). However, distinct nuances exist within the Ads ecosystem:

  1. Concurrency Limits: You are sending too many requests in parallel. Google limits the number of concurrent requests per developer token and per customer ID.
  2. QPS Spikes: Even if you are within your daily quota, sending 1,000 requests in a single second will trigger protection mechanisms to prevent "noisy neighbor" issues on Google's shards.
  3. Server-Side Load: Occasionally, specific Google backend shards are under heavy load. The API sheds traffic to maintain system stability.

The default behavior of most HTTP clients is to retry immediately. In a distributed system, if 50 threads fail and retry simultaneously, they create a "thundering herd," triggering the rate limiter again and extending the outage duration.

The Solution: Exponential Backoff with Jitter

The only mathematically sound way to handle this error is Exponential Backoff with Jitter.

  1. Exponential Backoff: Wait longer between each retry ($2^n$).
  2. Jitter: Add a random variance to the wait time to desynchronize your threads.

Without jitter, parallel workers that fail at the same time will retry at the same time, keeping the server overwhelmed.

Implementation in Python

While the Google Ads Python client has built-in retries, the default configuration is often too conservative for high-throughput SaaS applications. You need a custom configuration using tenacity (a robust retry library) or a custom interceptor.

Here is a production-grade implementation wrapping the Google Ads service client.

import time
import random
from typing import Callable, Any
from google.ads.googleads.client import GoogleAdsClient
from google.api_core import exceptions
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential_jitter,
    retry_if_exception_type,
    before_sleep_log
)
import logging

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class AdsMutationService:
    def __init__(self, client: GoogleAdsClient, customer_id: str):
        self.client = client
        self.customer_id = customer_id
        self.ads_service = client.get_service("GoogleAdsService")

    # Retry Strategy:
    # 1. Catch ResourceExhausted (gRPC 8) and ServiceUnavailable (gRPC 14)
    # 2. Wait 2^x * 1 second + random jitter
    # 3. Stop after 5 attempts to prevent infinite loops
    @retry(
        retry=retry_if_exception_type(
            (exceptions.ResourceExhausted, exceptions.ServiceUnavailable)
        ),
        wait=wait_exponential_jitter(initial=1, max=60, jitter=1),
        stop=stop_after_attempt(5),
        before_sleep=before_sleep_log(logger, logging.WARNING)
    )
    def mutate_campaigns(self, operations: list) -> Any:
        """
        Executes mutations with robust error handling.
        """
        if not operations:
            return None

        # Execute the mutation
        response = self.ads_service.mutate(
            customer_id=self.customer_id,
            mutate_operations=operations,
        )
        
        return response

def main():
    # Initialize client (assumes google-ads.yaml is in path)
    client = GoogleAdsClient.load_from_storage()
    
    # Mock Customer ID
    customer_id = "1234567890"
    
    service = AdsMutationService(client, customer_id)
    
    # Create an operation (Example: Pausing a campaign)
    campaign_service = client.get_service("CampaignService")
    campaign_operation = client.get_type("MutateOperation")
    campaign = campaign_operation.campaign_operation.update
    
    # Mock Resource Name
    campaign.resource_name = client.get_service("CampaignService").campaign_path(customer_id, "987654321")
    campaign.status = client.enums.CampaignStatusEnum.PAUSED
    campaign_operation.campaign_operation.update_mask.paths.append("status")

    try:
        # Send a single operation (In reality, batch these!)
        response = service.mutate_campaigns([campaign_operation])
        print(f"Mutation successful: {response}")
    except Exception as e:
        print(f"Final failure after retries: {e}")

if __name__ == "__main__":
    main()

Implementation in Java (Client Interceptor)

In Java, the cleanest way to handle this globally is via a gRPC ClientInterceptor. This ensures that every call made by your application adheres to the retry policy without polluting your business logic.

import io.grpc.*;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;

public class RateLimitInterceptor implements ClientInterceptor {

    private static final Logger logger = Logger.getLogger(RateLimitInterceptor.class.getName());
    private static final int MAX_RETRIES = 5;
    private static final long INITIAL_BACKOFF_MS = 1000;

    @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
            MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {

        return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(next.newCall(method, callOptions)) {
            
            private int attempt = 0;

            @Override
            public void sendMessage(ReqT message) {
                // Keep a reference to the message for retries
                super.sendMessage(message);
            }

            @Override
            public void start(Listener<RespT> responseListener, Metadata headers) {
                super.start(new ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>(responseListener) {
                    @Override
                    public void onClose(Status status, Metadata trailers) {
                        if (shouldRetry(status) && attempt < MAX_RETRIES) {
                            attempt++;
                            long backoff = calculateBackoff(attempt);
                            
                            logger.warning("Rate limited (Code 8). Retrying attempt " + attempt + " in " + backoff + "ms");
                            
                            try {
                                Thread.sleep(backoff);
                                // Re-trigger the call logic here. 
                                // Note: In a real async gRPC stub, you would schedule 
                                // the retry on an executor rather than blocking.
                                next.newCall(method, callOptions).start(this, headers);
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                            }
                        } else {
                            super.onClose(status, trailers);
                        }
                    }
                }, headers);
            }
        };
    }

    private boolean shouldRetry(Status status) {
        return status.getCode() == Status.Code.RESOURCE_EXHAUSTED ||
               status.getCode() == Status.Code.UNAVAILABLE;
    }

    private long calculateBackoff(int attempt) {
        // Exponential backoff: 1s, 2s, 4s, 8s...
        long base = INITIAL_BACKOFF_MS * (1L << (attempt - 1));
        // Add Jitter (randomness between 0 and 500ms)
        long jitter = (long) (Math.random() * 500);
        return base + jitter;
    }
}

Architectural Strategy: Mutate vs. BatchJobService

Even with perfect retries, there is a physical limit to how many synchronous API calls you can make. If your application handles thousands of updates daily, standard Mutate calls are inefficient.

The Tipping Point

If you are modifying more than 5,000 entities or sending requests that take longer than 60 seconds to process, stop using standard GoogleAdsService.Mutate.

Switch to BatchJobService (formerly MutateJobService).

Why BatchJobService Prevents Resource Exhaustion

  1. Asynchronous Processing: You upload the operations, and Google executes them on their infrastructure when resources are available.
  2. Higher Limits: Batch jobs support millions of operations per job.
  3. Automatic Retries: The Google Ads backend handles transient failures and transaction isolation for you.
  4. Reduced Network Overhead: You aren't holding open HTTP/2 connections waiting for responses.

Batch Job Workflow

  1. Create a BatchJob.
  2. AddOperations to the job (in chunks, usually 1,000 - 5,000 ops per request).
  3. Run the job.
  4. Poll for status (using exponential backoff on the polling, not tight loops!).

Common Pitfalls and Edge Cases

1. Grouping by Customer ID

Never iterate through a list of mixed-customer operations and fire requests one by one. Group your operations by LoginCustomerId and TargetCustomerId. The API requires a context switch for different customer IDs, which adds latency and increases the likelihood of timeouts.

2. Ignoring Partial Failures

In standard Mutate calls, setting partial_failure=True allows valid operations to succeed even if others fail. If you don't use this, a single RESOURCE_TEMPORARILY_EXHAUSTED on the 4,999th item in a batch of 5,000 will rollback the entire transaction.

Use partial_failure=True and inspect the result for errors to retry only the specific failed lines.

3. The "Retry-After" Header

Sometimes, the RESOURCE_TEMPORARILY_EXHAUSTED error comes with a Retry-After metadata header.

  • Always check headers: If Google explicitly tells you to wait 30 seconds, waiting 2 seconds (your calculated backoff) will result in an immediate rejection.
  • Priority: Retry-After header value > Calculated Exponential Backoff.

Conclusion

Handling RESOURCE_TEMPORARILY_EXHAUSTED separates robust enterprise applications from brittle scripts. By implementing client-side exponential backoff with jitter and recognizing when to offload heavy lifting to BatchJobService, you ensure your application remains stable during peak load.

Start by auditing your current try/catch blocks. If you find a simple time.sleep(1) inside a loop, you have identified your next technical debt sprint.