Repository navigation
fix(core): back off longer, with jitter, when a destroy is throttled - #952
Conversation
API Gateway allows one DeleteRestApi request every 30 seconds per account. When several stacks in one account are torn down at once, CloudFormation's own SDK retries run out and the stack ends DELETE_FAILED with "Too Many Requests (Service: ApiGateway, Status Code: 429)". runDestroyWithRetries then retried on a fixed 60s/120s schedule, so parallel destroys retried in lockstep, collided again, and gave up after 3 attempts (about 13 minutes), while observed throttle windows lasted up to about 30 minutes. After a failed attempt, read the stacks' CloudFormation events back to this attempt's own DELETE_IN_PROGRESS. If a resource DELETE_FAILED with a throttle reason, retry with exponential backoff and equal jitter (1, 2, 4, then 5 minute steps, each a random point in [step/2, step]) for up to 20 minutes after the first failure. Any other failure, or one whose events cannot be read, keeps the existing 60s/120s schedule. Both customer destroy paths (sandbox:destroy and production destroy) use this loop.
Review follow-ups for the throttled-destroy backoff: - Match throttle reasons case-sensitively on the services' error tokens and never read stack-level events (the "failed to delete: [ids]" summary, nested stacks) as a throttle, so a resource named like "ThrottlingPlan" cannot turn an ordinary failure into a long retry. Also recognise EC2 RequestLimitExceeded and S3 SlowDown. - Count a throttle only once this attempt's own stack DELETE_IN_PROGRESS is found, so a recent throttle from the previous attempt is not counted when the next attempt never started a delete. - Track ordinary retries separately, so non-throttle failures get at most the 60s and 120s waits per call regardless of throttled retries in between. - Warn (fall back to the ordinary schedule) when none of the stacks is found in the default Region. - More tests: mixed sequences, nested and summary events, skew window, error propagation, page cap.
Second round of review follow-ups: - Once throttle retries have used up the budget, give no further ordinary retries, so one destroy call stays within the budget plus one attempt (about 27 minutes with 5-minute attempts) instead of up to about 40. - Pass the previous attempt's failure time to the throttle check and ignore events up to it, so a short previous attempt whose throttle sits inside the clock-skew window is not counted again. - Read up to 30 pages of stack events so a large stack's first delete attempt is still classified. - Match "Rate Exceeded" (Lambda) and reword the all-stacks-missing warning.
🦋 Changeset detectedLatest commit: f51c712 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
On the review body: the mixed throttle-plus-real-failure behaviour is intended and now stated in the |
osama-rizk
left a comment
There was a problem hiding this comment.
Since the last review: 4 resolved · 0 new · 0 still open
This makes throttled destroys back off with jitter inside a bounded budget, and after the follow-ups it's ready from my side. I re-checked each item against the code at 8bb0af6, not just the replies. The history was rewritten on the same base, but 680dff2 has the same tree as the 4db0cf5 I reviewed (only the commit messages changed), so the earlier review still applies to that code.
Resolved:
- Worst case documented. The changeset, the
runDestroyWithRetriesJSDoc and the description now say a destroy that stays throttled can run about 30 minutes (about 18 before). My fake-clock run at this head gives 31 / 27 / 18 minutes (mixed, all-throttled, all-ordinary) with 5-minute attempts, which matches. Keeping the budget fixed and leaving the override to maintainers is a sound answer. - Wiring guarded.
sandboxDestroyDeps/productionDestroyDepsare pinned by the new call-site tests. DeletingisThrottled: destroyWasThrottledfrom either compiled path now fails a test. ThedestroyWasThrottled([], …)case calls no AWS API. - Budget and bucket mutants killed. Dropping
+ nextfrom the budget check, and skippingemptyBucketson a throttle retry, both fail the suite now. I also tried four mutants on the new code: no post-budget guard, letting the budget cut ordinary retries when there was no throttle, no throttle hint, and always printing the budget-used hint. Each one fails a test. - Failure hint. Both exhaustion messages print in the right cases and are tested. The "used up the retry time" branch can only be reached when throttle retries spent the budget.
What I verified:
- Root build OK.
sandbox-destroy-throttle+sandbox-empty-buckets: 42/42 pass. - The public API is unchanged.
scripts/index.tsstill exports onlydestroyanddestroySandbox. - No
E2E:trailer on any commit. The full PR Checks run 39970264690 on 8bb0af6 passed every E2E (end-to-end) job, including Sandbox, Production, Hosting (all 9) and Telemetry, except Compute Smoke. Compute Smoke failed incdk deploy BlocksComputeTestVpc("Cannot delete export … in use by BlocksComputeTestVpcEndpoints") before any test ran. Runs 39974123347 and 39980152214 on unrelated PRs fail the same way the same day, and this PR touches notest-infra/or workflow files, so the failure is unrelated.Build and Testonly aggregates that job.bench / summaryis not required (9 of 10 cellsisolation_unavailable).
Non-blocking follow-ups (maintainer decisions, not for this PR):
- An optional budget override for customers or CI. It's customer-facing, so it should be a product decision.
- A
throttleRetriestelemetry counter, so we can see how often customers hit this. - CI still never ran the throttle branch: no 429 showed up in this run's destroys. The ordinary 60s/120s path did run in Hosting (nuxt-web-function) and recovered. The first real throttle will be the real test, and the logs will show it.
Blocking: none. Open questions for the author: none.
Problem
API Gateway allows one
DeleteRestApirequest every 30 seconds per account. This quota is fixed and cannot be raised. When several stacks in one account tear down at once, CloudFormation's own SDK retries run out, and the stack endsDELETE_FAILED:runDestroyWithRetries, the retry loop behind bothsandbox:destroyand productiondestroy, then retried on a fixed 60s/120s schedule:Data:
DELETE_FAILEDin the E2E accounts today show the same resource reason.Change
After a failed attempt,
runDestroyWithRetriesasksisThrottledwhether this attempt was throttled. The real implementation isdestroyWasThrottled, which usesDescribeStackEvents:DELETE_IN_PROGRESS, and ignores anything up to the previous attempt's failure.DELETE_FAILEDwhose reason carries a throttle token counts as a throttle. The tokens areToo Many Requests,Status Code: 429,TooManyRequestsException,ThrottlingException,Error Code: Throttling,Rate exceeded,RequestLimitExceededandError Code: SlowDown. Matching is case-sensitive, and stack-level summary and nested-stack events are skipped.How each kind of failure is retried:
Why these numbers:
runDestroyWithRetriesJSDoc says so.Other details:
sandboxDestroyDepsandproductionDestroyDeps, are not exported from@aws-blocks/core/scripts.cdk lswith a default-Region client, so a stack in another Region (the Lambda@Edge stack) is not read and falls back to the ordinary schedule. The per-Region stack list in fix(core): destroy and watch every top-level stack of a multi-stack app #968 fixes that for this detection too; this PR does not duplicate it.@aws-sdk/client-cloudformationis already used here.@aws-blocks/corepatch.Not changed here (load spreading)
The quota is per account. A full PR Checks run deletes about 15 REST APIs from 12 stacks within about 20 to 30 minutes. Parallel PRs that share a pool account add to that. The Hosting matrix is the hot spot. Spreading the load would remove more of the 429s than any retry can:
test-apps/compute-smoke/test/sandbox-destroy.tscalls rawcdk destroy --allwith no retry, so it does not benefit from this change. Its stacks are the live leaks.Tests
packages/core/src/scripts/sandbox-destroy-throttle.test.ts.destroyWasThrottledandemptySandboxBuckets(deleting either line fails a test); the pending wait counts against the budget; buckets are emptied before each throttle retry; slow attempts that were never throttled keep both ordinary retries; both exhaustion messages.npm run build,lint:deps,test:e2e:localandlintpass.npm testpasses except core'stelemetry.test.js, which hung only while two lane containers ran at once; core's suite re-run alone passes (0 failures), and CI's unit job passes.E2E:trailer, so this is a full PR Checks run.BlocksComputeTestVpcstack update was refused ("Cannot delete export ... as it is in use by BlocksComputeTestVpcEndpoints"), a test-infrastructure mismatch in the pool account.Build and Testaggregates that job.bench / summaryfails its floor with no scored cell (mean composite 0.0), as on earlier runs; not a required check.