Throttling, quotas, retries and idempotency

Amazon Web Services · Expert · 18 min

At scale every AWS API pushes back, and naive retries turn a small throttle into a self-inflicted outage.

Every AWS API is rate limited, usually by token buckets per account, Region and operation (EC2 Describe* calls share buckets, for example). Throttles surface as ThrottlingException, TooManyRequestsException, RequestLimitExceeded, or HTTP 503 SlowDown from S3, which scales per key prefix (thousands of requests per second per prefix, growing gradually). Service Quotas are separate: limits on resources and rates, either adjustable (request an increase, ideally before launch, and alarm on usage metrics) or fixed.

Retry well:

  • Exponential backoff with full jitter: sleep a random time between 0 and min(cap, base * 2^attempt), so retrying clients spread out instead of synchronising.
  • Bound everything: few attempts, connect and read timeouts below your caller's timeout, and retry only at one layer; retries multiplied across layers amplify load.
  • SDK standard mode adds a retry quota that stops retrying when most calls fail; adaptive mode adds client-side rate limiting.

Idempotency makes retries safe. Many create APIs accept a token (ClientToken on EC2 RunInstances, ClientRequestToken in CloudFormation and Secrets Manager): repeating a request with the same token returns the original result instead of creating a duplicate. Apply the same idea in your own APIs with idempotency keys.

Eventual consistency in control planes: a just-created resource may not be visible to Describe calls for a moment, and IAM changes take seconds to propagate. Use waiters, and retry NotFound briefly after creates.

Finally, keep control-plane calls (create, modify) out of recovery paths; rely on data-plane operations and pre-provisioned capacity.

Example

import random, time, uuid
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
cfg = Config(retries={"mode": "adaptive", "max_attempts": 8}, connect_timeout=2, read_timeout=10)
ec2 = boto3.client("ec2", region_name="eu-west-1", config=cfg)
token = str(uuid.uuid4())   # ec2.run_instances(..., ClientToken=token): safe to retry

def full_jitter(op, attempts=5, base=0.1, cap=5.0, codes=("ThrottlingException",)):
    for n in range(attempts):
        try:
            return op()
        except ClientError as e:
            if e.response["Error"]["Code"] not in codes or n == attempts - 1:
                raise
            time.sleep(random.uniform(0, min(cap, base * 2 ** n)))

throttle = ClientError({"Error": {"Code": "ThrottlingException"}}, "PutItem")
outcomes = iter([throttle, throttle, "ok"])
def flaky():
    r = next(outcomes)
    if isinstance(r, Exception):
        raise r
    return r
print(full_jitter(flaky))   # ok  (after two throttled attempts)

Remember

  • Expect throttling; back off exponentially with full jitter
  • Bound retries and timeouts, and retry at one layer only
  • Use idempotency tokens so retried creates don't duplicate
  • Control planes are eventually consistent: use waiters and keep them out of recovery paths

Watch out for

  • Retrying in the SDK, the application and the load balancer at once, multiplying traffic during an incident.

Check yourself

  1. Why add random jitter to exponential backoff?
  2. A RunInstances call times out and you retry with the same ClientToken. What happens if the first call actually succeeded?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html