Runbook: Investigate Provider API Rate Limiting
1 · Prerequisites
Confirm every item is in place before any state change.
- Provider Failures and Recovery
- Parallelism and -parallelism
- Troubleshooting Apply Failures
- Terraform Logging and Debugging
- Refresh and Plan: How They Interact
- CI/CD Observability and Failures
- Read access to the cloud account's quota or throttling metrics, and the ability to pause the CI apply job for this workspace
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · The exact command, exit code, first failing resource address, provider operation and wall-clock failure time are captured before anything is re-run
- · The error string is classified:
ThrottlingException,Rate exceeded,TooManyRequestsExceptionor HTTP 429 is a RATE problem;LimitExceeded,QuotaExceededor "maximum number of" is a COUNT problem and belongs to the quota runbook - · The identity the run used is recorded. Throttling is metered per account, per region and per service, so "which account" is part of the evidence, not context
- ·
terraform plan -detailed-exitcodehas been run once to establish whether the failed apply was partial. Exit 2 means some resources exist and some do not - · The number of Terraform processes that were running against the same account and region at the failure minute is known - CI matrix legs, scheduled drift detection, and engineers on laptops all count
- · Nothing has been re-run yet. A blind retry into an active throttle both fails and extends the throttle window
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Classify rate versus count. Lowering
-parallelismdoes nothing for a quota error, and requesting a quota increase does nothing for a throttle. Getting this wrong costs a day of waiting on the wrong ticket - 2Establish the state position. Run
terraform plan -detailed-exitcodeand read what it proposes. A throttled apply is a partial apply: some resources were created before the failure and are already in state - 3Count the producers of request rate at the failure minute. Terraform concurrency is per process. A six-leg CI matrix at the default parallelism of 10 is up to 60 concurrent operations against one account, and no single run looks unreasonable
- 4Name the throttled API. Re-run the plan with
TF_LOG_PROVIDER=DEBUGandTF_LOG_PATHunderumask 077, then grep the log for the throttled operation. "AWS is throttling us" is not actionable; "IAM CreatePolicyVersion is throttling us" is - 5Find what changed. The apply did not get faster on its own. Look for a new matrix leg, a newly enabled continuous drift-detection job, a
for_eachthat grew, or a noisy neighbour in the same account - 6Choose a mitigation, in this order: hold until the window passes; reduce the number of concurrent runs; reduce
-parallelism; and only then touch provider retry settings - 7If holding: set an owner and an end time, and say both out loud in the incident channel. A hold with no end time is an outage that nobody has declared
- 8Re-run at the chosen setting and time it.
time terraform apply -parallelism=N. Compare against the previous duration - 9Check what the mitigation cost. The state lock is held for the whole apply regardless of parallelism, so a slower apply is a longer lock. If the new duration pushes other teams into lock waits, the incident has moved rather than closed
- 10**Verify with
terraform plan -detailed-exitcode** and require exit 0 before the apply job is unpaused - 11Record the throttled API name, the measured concurrency at failure, and the mitigation that was applied. "It was flaky, it cleared" is the note that guarantees a repeat
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓
terraform plan -detailed-exitcodeexits 0 after the re-run. Exit 2 means the apply is still incomplete, not that it succeeded slowly - ✓The provider debug log from the successful run contains no throttling entries:
grep -icE "throttl|429|rate exceeded|toomanyrequests" /tmp/tf-provider.logreturns 0 - ✓The incident note names a specific API operation, not a provider. The operation is the thing you can rate-limit, split out, or request an increase for
- ✓The measured apply duration at the new
-parallelismis recorded, and it is inside the lock budget other consumers of this state depend on - ✓If the fix was a concurrency reduction in CI, the change is in the pipeline definition in version control - not in a runner environment variable somebody set by hand
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Diagnosis here is read-only. Steps 1 to 5 change nothing and there is nothing to undo
- ↶A lowered
-parallelismis reverted by removing the flag or theTF_CLI_ARGS_applyvalue. Revert it deliberately once the cause is fixed; a permanently low value silently lengthens every apply and every lock hold - ↶If
-refresh=falsewas used to get a fast plan past a throttled refresh, that plan was computed against the last-written state and not against reality. Discard it and re-plan with refresh before applying anything - ↶If provider retry settings were raised, put them back. A larger retry budget makes the apply hold the lock longer while it waits, which is the opposite of what an incident needs
- ↶A partial apply is not rolled back by editing state. Re-run plan and apply; Terraform converges, and hand edits introduce drift that the next plan cannot see
6 · Escalation
When the runbook isn't enough, contact:
- · Escalate to the cloud account owner when the throttled API is shared with systems outside Terraform. Lowering Terraform concurrency will not fix a limit that another workload is consuming
- · Escalate to the platform team when the same API throttles across more than one workspace. That is an account-level capacity conversation and a candidate for splitting the throttling-prone resources into their own state and apply
- · Escalate to the cloud provider for a rate-limit increase only after the throttled operation is named and the current concurrency is measured. A request without both is refused or answered slowly
- · Escalate to whoever owns the change window if the mitigation is a hold. Waiting is a legitimate action, but it needs an owner and an end time
- · Escalate to the quota runbook, not this one, if the error turns out to be
LimitExceededor an object-count limit
A throttled apply is rarely a Terraform problem, and it is almost never caused by Terraform getting faster. The request rate arriving at the cloud API is the sum of several independent producers, and an incident happens when one of them changes while everybody is looking at the run that failed.
There are three producers, and only the first is visible in the command that failed:
- This run.
-parallelismbounds concurrent resource operations and defaults to 10. - The other runs. Parallelism is per process. Six CI matrix legs at the default is up to sixty concurrent operations against one account, and every individual run looks reasonable.
- Everything else in the account. The API is shared with autoscaling, other tooling, other teams, and the console. Terraform is a tenant, not the owner.
The runbook is mostly about producer 2 and producer 3, because the engineer holding the pager can already see producer 1 and will instinctively reach for it.
When this runbook applies
- An apply failed with
ThrottlingException,Rate exceeded,TooManyRequestsException, or an HTTP 429. - A plan hangs during refresh against a large estate rather than failing outright.
- Applies that used to take four minutes now take twenty, with retries visible in the provider log.
When it does not
Also out of scope: a single slow apply. One slow run is a graph problem or a slow API. Throttling shows up as retries in the log and as a trend.
The state is the first question, not the last
The instinct after a failed apply is to fix the throttle. Do the cheaper check first, because it changes what “fixed” means:
terraform plan -detailed-exitcode -no-color
echo "exit=$?"
terraform state list | wc -lExit 0 means the apply finished everything it needed to and the error arrived on the way out. Exit 2 means resources exist that the plan still wants to create, or the reverse: this was a partial apply.
That is normal and it is recoverable. Terraform does not roll back on failure; the state records what succeeded, and the next apply converges by creating what is missing. What is not recoverable is somebody “cleaning up” the half-finished state by hand. A manual edit diverges the state from reality in a way the next plan cannot detect.
Step 1: name the throttled operation
“AWS is throttling us” is not an actionable statement. Different APIs in the same account throttle at wildly different rates - IAM and Route 53 are far tighter than EC2 - so the operation name is what determines whether the answer is “lower concurrency”, “split this resource type into its own apply”, or “request an increase”.
umask 077
TF_LOG_CORE=INFO TF_LOG_PROVIDER=DEBUG \
TF_LOG_PATH=/tmp/tf-provider.log \
terraform plan -input=false -no-color
grep -inE 'throttl|429|rate exceeded|toomanyrequests' /tmp/tf-provider.log | head -20The error the provider surfaces looks like this - the useful fields are the operation and the exception name, not the line number:
Error: creating EC2 Instance: ThrottlingException:
Rate exceeded
Step 2: count what else was running
# What concurrency did this run request? The flag is on the command
# line or in TF_CLI_ARGS; when neither is set, the default is 10.
env | grep -E '^TF_CLI_ARGS' || echo 'not set - default parallelism 10'
# What does the pipeline fan out to, and what does it serialise on?
grep -rnE 'matrix:|parallelism|concurrency:' .github/workflows/ | head -20.github/workflows/terraform.yml:14: matrix:
.github/workflows/terraform.yml:16: env: [dev, staging, prod-eu, prod-us, sandbox, ci]
.github/workflows/terraform.yml:41: concurrency: terraform-apply-${{ matrix.env }}Illustrative output
Read that concurrency: line carefully. It is keyed per environment,
which is exactly right for preventing two applies from racing for the
same state lock - and it does nothing at all to bound the total
request rate, because six different environments in the same account
still hit the same API.
That is the distinction the incident usually turns on: the pipeline is protecting the state, and nobody has been protecting the API.
Step 3: choose a mitigation, in order
The order matters, because each option costs more than the one above it.
| Option | Cost | Use when |
|---|---|---|
| Hold until the window passes | Wall-clock, with an owner and an end time | The change is not urgent and the throttle is transient |
| Reduce concurrent runs | CI throughput | Several runs share one account and region |
Lower -parallelism | Longer apply, longer lock hold | One run is genuinely the whole load |
| Change provider retry settings | Longest lock hold of all | Last, and rarely |
Hold is a real option. It is the only one that costs nothing but time, and it is the right answer more often than its reputation suggests. Give it an owner and an end time in the incident channel; without those two it stops being a decision and becomes an undeclared outage.
time terraform apply -parallelism=4
# Or, for a pipeline, set it where it is reviewable:
export TF_CLI_ARGS_apply="-parallelism=4"Step 4: on provider retry settings, and why they are last
By the time you see a throttling error, the provider has already
retried. The AWS provider defaults max_retries to 25 with
exponential backoff, and retry_mode defaults to standard; the
adaptive mode adds client-side rate limiting on top. Raising the
retry budget does not make the API faster - it makes the apply spend
longer waiting, while holding the lock, before failing anyway.
provider "aws" {
region = "eu-west-2"
# Already 25 by default. Raising it lengthens the failure,
# it does not prevent it.
max_retries = 25
# "adaptive" adds client-side rate limiting; it is a considered
# change to a configuration, not an incident-time lever.
retry_mode = "standard"
}
There is a legitimate use for retry_mode = "adaptive" on a
configuration that consistently saturates a tight API. It belongs in a
pull request with a measured before-and-after, not in a hotfix.
The refresh case is different
Throttling during refresh does not usually produce an error. It
produces a terraform plan that hangs, because refresh reads every
resource in state and a large state times that read against the
tightest API in the configuration.
-refresh=false gets you a fast plan, and the trade is not small: the
plan is computed against the state as last written, not against
reality. If anything drifted, the plan proposes changes based on a
fiction.
# Fast, but blind to drift since the last write.
terraform plan -refresh=false -detailed-exitcode
# Refresh separately when the API is quiet, then plan normally.
terraform apply -refresh-onlyUse -refresh=false to answer “what does my configuration change?”
during an incident. Never apply a plan built that way to production
without a normal refresh first.
What “fixed” looks like
Containment is a lowered concurrency setting and a run that completes.
That is not the same as fixed, and the difference is worth naming
before the incident is closed, because a lowered -parallelism left in
place is a permanent tax that nobody remembers agreeing to.
There are three durable outcomes, and which one applies depends on the producer the investigation landed on:
Producer 2 - too many runs. The fix is in the pipeline definition,
in version control, where it can be reviewed. A concurrency: group
keyed per environment serialises applies against the same state; it
does not bound the request rate across environments. If six
environments share an account, the bound has to be at the account
level - a single serialising group across those legs, or a schedule
that staggers them.
A tight API mixed in with everything else. If only IAM or DNS operations throttle while the rest of the apply is comfortable, the resources that use them are the wrong tenants of this state. Moving them into their own configuration and their own apply gives them their own pace without slowing the resources that were never the problem. This is a state-boundary change, so it is planned work with the workspace owner, not an incident action - but it is the change that stops the recurrence.
Producer 3 - the account is genuinely at its rate ceiling. Then the ticket is an increase request, and it needs the named operation and the measured concurrency to be actioned. A request that says “we get throttled sometimes” gets the answer it deserves.
The signal that predicts this
None of these incidents arrive without warning; they arrive without anyone watching the warning. Three of the metrics the CI observability lesson defines are the early signal, and they are cheap to alert on:
terraform_provider_errors_total, labelled by provider. Throttling that the provider retried successfully never reaches a human, but it does increment this counter. A rising rate with no failed applies is the month before the incident.terraform_plan_duration_secondsat the 90th percentile. Retries with exponential backoff show up here first, as duration, long before they show up as failure.terraform_state_lock_wait_seconds. This is the metric that tells you whether a lowered-parallelismhas been quietly costing other teams since the last incident.
A team that alerts on the first and reviews the second weekly gets to handle this as a planning conversation. A team that does not gets to handle it at 3am with a partial apply.
Common patterns
| Symptom | Likely producer | First move |
|---|---|---|
| Throttling started the day a matrix leg was added | Producer 2 | Count concurrent runs before touching -parallelism |
| Only IAM or Route 53 operations throttle | Tight API, normal load | Split those resources into their own state and apply |
| Throttling with one run and low parallelism | Producer 3 | Ask who else uses this account; check quota metrics |
| Plan hangs, apply is fine | Refresh against a large state | Refresh separately when the API is quiet |
| Lower parallelism fixed it, but other teams now wait on the lock | Traded one incident for another | Revert; fix concurrency in CI instead |
LimitExceeded, not Rate exceeded | Not a rate problem at all | Quota runbook |
| Cleared on its own, no note written | Nothing was learned | It will recur, and the next engineer starts from zero |