Skip to content

Commit 867f9d3

Browse files
authored
fix(deployment): retry AWS throttling that surfaces as HTTP 400 by error name (#861)
1 parent c93b143 commit 867f9d3

18 files changed

Lines changed: 840 additions & 6 deletions

docs/_generated/integ-coverage.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2291,6 +2291,7 @@
22912291
"rollback-failure-injection",
22922292
"sdk-ccapi-crossref",
22932293
"tags-propagation",
2294+
"throttle-wide-dag",
22942295
"update-replace",
22952296
"vpc-lambda-cr-race"
22962297
],
@@ -2411,6 +2412,10 @@
24112412
"l2",
24122413
"literal"
24132414
],
2415+
"throttle-wide-dag": [
2416+
"l1",
2417+
"literal"
2418+
],
24142419
"update-replace": [
24152420
"l2",
24162421
"literal"
@@ -3320,6 +3325,7 @@
33203325
"serverless-api",
33213326
"sns-sqs-event",
33223327
"tags-propagation",
3328+
"throttle-wide-dag",
33233329
"update-policy-mutations"
33243330
],
33253331
"signals": {
@@ -3395,6 +3401,10 @@
33953401
"tags-propagation": [
33963402
"l2"
33973403
],
3404+
"throttle-wide-dag": [
3405+
"l1",
3406+
"literal"
3407+
],
33983408
"update-policy-mutations": [
33993409
"l2",
34003410
"literal"
@@ -3565,6 +3575,7 @@
35653575
"secrets-dynamic-ref",
35663576
"state-info-command",
35673577
"tags-propagation",
3578+
"throttle-wide-dag",
35683579
"update-policy-mutations",
35693580
"vpc-lookup"
35703581
],
@@ -3692,6 +3703,10 @@
36923703
"l2",
36933704
"literal"
36943705
],
3706+
"throttle-wide-dag": [
3707+
"l1",
3708+
"literal"
3709+
],
36953710
"update-policy-mutations": [
36963711
"l2",
36973712
"literal"

docs/_generated/integ-last-run.tsv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,4 @@ rollback-failure-injection 2026-06-13T11:11:55Z PASS 418 verify.sh deploy-engine
130130
deployment-events 2026-06-13T11:42:29Z PASS 37 verify.sh #831 +SNS/SSM not-found post-destroy; 0 err
131131
update-replace 2026-06-13T11:42:29Z PASS 71 verify.sh #831 +Lambda/IAM/SG not-found post-destroy; 0 err
132132
destroy-interrupt 2026-06-13T11:42:29Z PASS 581 verify.sh #831 +SG/IAM not-found post-destroy; #816+#804 verified; 0 orphan
133+
throttle-wide-dag 2026-06-14T04:35:46Z PASS verify.sh bug15 throttle-retry validated deploy+destroy clean

docs/_generated/scenario-coverage.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@
259259
{
260260
"tag": "vpc-lambda-eni-release",
261261
"description": "Lambda hyperplane ENI cleanup after DeleteFunction (5-30 min eventually consistent)."
262+
},
263+
{
264+
"tag": "wide-dag-throttle-retry",
265+
"description": "Wide (~100-resource: 80 SSM Parameters + 10 IAM Roles + 10 SNS Topics, 10-deep SSM Fn::Sub chain) single-stack burst deployed under a HIGH `--concurrency` to stress the concurrency limiter + event-driven DAG executor + throttle/retry classifier: a `TooManyRequests` / `Rate exceeded` / HTTP 429 during the burst must be RETRIED (deploy still succeeds) not fatal, the chained subset proves strict DAG ordering, and the destroy burst absorbs ~100 deletes with 0 orphans."
262266
}
263267
],
264268
"fixtures": [
@@ -1161,6 +1165,13 @@
11611165
"stack-level-tag-propagation-multitype"
11621166
]
11631167
},
1168+
{
1169+
"name": "throttle-wide-dag",
1170+
"annotated": true,
1171+
"scenarios": [
1172+
"wide-dag-throttle-retry"
1173+
]
1174+
},
11641175
{
11651176
"name": "update-policy-mutations",
11661177
"annotated": true,
@@ -1706,6 +1717,13 @@
17061717
"lambda",
17071718
"vpc-lambda"
17081719
]
1720+
},
1721+
{
1722+
"scenario": "wide-dag-throttle-retry",
1723+
"description": "Wide (~100-resource: 80 SSM Parameters + 10 IAM Roles + 10 SNS Topics, 10-deep SSM Fn::Sub chain) single-stack burst deployed under a HIGH `--concurrency` to stress the concurrency limiter + event-driven DAG executor + throttle/retry classifier: a `TooManyRequests` / `Rate exceeded` / HTTP 429 during the burst must be RETRIED (deploy still succeeds) not fatal, the chained subset proves strict DAG ordering, and the destroy burst absorbs ~100 deletes with 0 orphans.",
1724+
"fixtures": [
1725+
"throttle-wide-dag"
1726+
]
17091727
}
17101728
],
17111729
"orphanScenarios": [],

docs/changelog-cdkd.md

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

docs/integ-coverage.md

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

docs/scenario-coverage.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Run `vp run scenario-coverage` to regenerate.
66

7-
**65 / 65 canonical scenarios** have at least one integ fixture exercising them. **132 / 151 integ fixtures** carry a `.scenarios.json` sidecar (with 0+ tags); the rest are un-annotated and contributor-reviewed below.
7+
**66 / 66 canonical scenarios** have at least one integ fixture exercising them. **133 / 152 integ fixtures** carry a `.scenarios.json` sidecar (with 0+ tags); the rest are un-annotated and contributor-reviewed below.
88

99
## How this is computed
1010

@@ -26,7 +26,7 @@ This report is a visibility tool, not a commit-time gate. Many cdkd fixtures leg
2626

2727
_None._ Every canonical scenario has at least one integ fixture tagged with it.
2828

29-
## Per-scenario coverage (65 scenarios)
29+
## Per-scenario coverage (66 scenarios)
3030

3131
| Scenario | Description | Integ Fixture(s) |
3232
|---|---|---|
@@ -95,6 +95,7 @@ _None._ Every canonical scenario has at least one integ fixture tagged with it.
9595
| `update-replace-breadth` | Second-deploy property mutation exercising BOTH cdkd update paths in one stack: in-place provider.update() (S3 versioning toggle / Lambda env+memory / IAM inline-policy edit / SecurityGroup ingress add — physical id unchanged) AND replacement (S3 BucketName change per the replacement-rules registry — new physical id, old resource cleaned up). Regression net for provider update() paths + #807 replacement propagation + #809 Cloud Control write-only-property UPDATE on non-ECS types. | [`update-replace`](../tests/integration/update-replace/) |
9696
| `vpc-lambda-cr-race` | Custom Resource invocation against a VPC Lambda mid-deploy (ENI-attach race window). | [`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) |
9797
| `vpc-lambda-eni-release` | Lambda hyperplane ENI cleanup after DeleteFunction (5-30 min eventually consistent). | [`bench-cdk-sample`](../tests/integration/bench-cdk-sample/)<br>[`destroy-interrupt`](../tests/integration/destroy-interrupt/)<br>[`lambda`](../tests/integration/lambda/)<br>[`vpc-lambda`](../tests/integration/vpc-lambda/) |
98+
| `wide-dag-throttle-retry` | Wide (~100-resource: 80 SSM Parameters + 10 IAM Roles + 10 SNS Topics, 10-deep SSM Fn::Sub chain) single-stack burst deployed under a HIGH `--concurrency` to stress the concurrency limiter + event-driven DAG executor + throttle/retry classifier: a `TooManyRequests` / `Rate exceeded` / HTTP 429 during the burst must be RETRIED (deploy still succeeds) not fatal, the chained subset proves strict DAG ordering, and the destroy burst absorbs ~100 deletes with 0 orphans. | [`throttle-wide-dag`](../tests/integration/throttle-wide-dag/) |
9899

99100
## Un-annotated fixtures (19)
100101

scripts/build-scenario-coverage-matrix.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ const KNOWN_SCENARIOS: Record<string, string> = {
210210
// ---- Multi-resource / broad-regression set ----
211211
'cross-cutting-deploy-destroy':
212212
'Broad real-AWS regression set (39+ resource VPC+NAT+CF+Lambda+SQS or comparable breadth). Refreshes the integ-broad gate.',
213+
'wide-dag-throttle-retry':
214+
'Wide (~100-resource: 80 SSM Parameters + 10 IAM Roles + 10 SNS Topics, 10-deep SSM Fn::Sub chain) single-stack burst deployed under a HIGH `--concurrency` to stress the concurrency limiter + event-driven DAG executor + throttle/retry classifier: a `TooManyRequests` / `Rate exceeded` / HTTP 429 during the burst must be RETRIED (deploy still succeeds) not fatal, the chained subset proves strict DAG ordering, and the destroy burst absorbs ~100 deletes with 0 orphans.',
213215

214216
// ---- Intrinsic-function resolution patterns ----
215217
'intrinsics-torture':

src/deployment/retryable-errors.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,23 @@ export const RETRYABLE_ERROR_MESSAGE_PATTERNS: readonly string[] = [
146146
// teardown lock and avoid retrying unrelated create-already-exists
147147
// conflicts that share the same exception name.
148148
'because it is in use',
149+
// Throttling backstop: many AWS services surface a rate-limit rejection
150+
// with the canonical "Rate exceeded" message (SSM PutParameter, STS,
151+
// CloudWatch, API Gateway, etc.) and an HTTP 400 (NOT 429), so the status-
152+
// code check below misses them. When cdkd dispatches a wide DAG at a high
153+
// `--concurrency`, the create burst can exceed a per-service rate limit and
154+
// AWS rejects the losers with `Rate exceeded. Ensure you have the high-
155+
// throughput setting enabled ...`. The AWS SDK's own retry layer (3 fast
156+
// attempts) is not enough to drain a large burst; cdkd's outer withRetry —
157+
// with its longer 1s/2s/4s/8s backoff — spreads the remaining creates out
158+
// until the rate window clears. "Rate exceeded" only ever means throttling,
159+
// so a permanent failure cannot false-positive into the retry loop. This is
160+
// a message-level backstop for the name-based throttle detection in
161+
// isThrottlingError() (the ProvisioningError wrap preserves the SDK error's
162+
// message string even when the original `.name` is one cause-link deeper).
163+
// Surfaced by tests/integration/throttle-wide-dag (80 SSM parameters at
164+
// --concurrency 40).
165+
'Rate exceeded',
149166
];
150167

151168
/**
@@ -154,13 +171,58 @@ export const RETRYABLE_ERROR_MESSAGE_PATTERNS: readonly string[] = [
154171
*/
155172
export const RETRYABLE_HTTP_STATUS_CODES: ReadonlySet<number> = new Set([429, 503]);
156173

174+
/**
175+
* AWS SDK v3 canonical throttling error names. Mirrors
176+
* `@aws-sdk/service-error-classification`'s `THROTTLING_ERROR_CODES` — any
177+
* error (or wrapped cause) whose `name` is one of these is a transient rate-
178+
* limit rejection worth retrying with backoff. Detecting by NAME is more
179+
* robust than by HTTP status because most AWS throttles surface as HTTP 400
180+
* (not 429) with the throttling signal carried only in the error code / name
181+
* (e.g. SSM `ThrottlingException` for the `Rate exceeded` message).
182+
*/
183+
export const THROTTLING_ERROR_NAMES: ReadonlySet<string> = new Set([
184+
'BandwidthLimitExceeded',
185+
'EC2ThrottledException',
186+
'LimitExceededException',
187+
'PriorRequestNotComplete',
188+
'ProvisionedThroughputExceededException',
189+
'RequestLimitExceeded',
190+
'RequestThrottled',
191+
'RequestThrottledException',
192+
'SlowDown',
193+
'ThrottledException',
194+
'Throttling',
195+
'ThrottlingException',
196+
'TooManyRequestsException',
197+
'TransactionInProgressException',
198+
]);
199+
200+
/**
201+
* Walk the error + its `.cause` chain (bounded) looking for an AWS SDK v3
202+
* throttling error `name`. cdkd wraps the original AWS error in a
203+
* `ProvisioningError`, so the throttling signal is typically one cause-link
204+
* deep; the bounded walk also tolerates SDK errors that nest a `$response`/
205+
* cause without exploding on a cyclic chain.
206+
*/
207+
function isThrottlingError(error: unknown): boolean {
208+
let current: unknown = error;
209+
for (let depth = 0; depth < 5 && current != null; depth++) {
210+
const name = (current as { name?: unknown }).name;
211+
if (typeof name === 'string' && THROTTLING_ERROR_NAMES.has(name)) return true;
212+
current = (current as { cause?: unknown }).cause;
213+
}
214+
return false;
215+
}
216+
157217
/**
158218
* Determine whether an AWS error should be retried.
159219
*
160220
* Checks (in order):
161221
* 1. HTTP status code on the error itself (`$metadata.httpStatusCode`)
162222
* 2. HTTP status code on a wrapped cause (`cause.$metadata.httpStatusCode`)
163-
* 3. Substring match against {@link RETRYABLE_ERROR_MESSAGE_PATTERNS}
223+
* 3. Throttling error `name` on the error or any wrapped cause (most AWS
224+
* throttles are HTTP 400, not 429 — see {@link THROTTLING_ERROR_NAMES})
225+
* 4. Substring match against {@link RETRYABLE_ERROR_MESSAGE_PATTERNS}
164226
*/
165227
export function isRetryableTransientError(error: unknown, message: string): boolean {
166228
const metadata = (error as { $metadata?: { httpStatusCode?: number } }).$metadata;
@@ -171,5 +233,7 @@ export function isRetryableTransientError(error: unknown, message: string): bool
171233
const causeStatus = cause?.$metadata?.httpStatusCode;
172234
if (causeStatus !== undefined && RETRYABLE_HTTP_STATUS_CODES.has(causeStatus)) return true;
173235

236+
if (isThrottlingError(error)) return true;
237+
174238
return RETRYABLE_ERROR_MESSAGE_PATTERNS.some((p) => message.includes(p));
175239
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"scenarios": [
3+
"wide-dag-throttle-retry"
4+
]
5+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# throttle-wide-dag
2+
3+
Failure-seeking integration test that stresses cdkd's **concurrency limiter**
4+
(`src/deployment/dag-executor.ts`), its **throttle / retry classifier**
5+
(`src/deployment/retryable-errors.ts` + `src/deployment/retry.ts`), and the
6+
**event-driven DAG executor** at SCALE.
7+
8+
## Background
9+
10+
cdkd dispatches every ready resource as soon as its dependencies complete,
11+
bounded only by `--concurrency` across the whole stack. A wide stack — many
12+
independent resources created in one burst — is the case most likely to trip
13+
an AWS-side rate limit (`TooManyRequestsException` / `Rate exceeded`, surfaced
14+
as HTTP 429). The retry helper classifies 429 (and 503) as transient and backs
15+
off (`1s -> 2s -> 4s -> 8s`, capped). This fixture exists to verify that a
16+
throttle during a large burst is RETRIED (the deploy still succeeds) rather
17+
than treated as fatal — and to surface DAG-scheduling or partial-failure bugs
18+
that only appear at scale.
19+
20+
The resources are deliberately CHEAP, fast, and quota-friendly so the fixture
21+
can run at ~100 resources without cost or quota blow-ups. No VPC — every
22+
resource is a control-plane-only create.
23+
24+
## Fixture
25+
26+
`CdkdThrottleWideDagExample` (`lib/throttle-wide-dag-stack.ts`) — ~100 resources:
27+
28+
| Type | Count | Role |
29+
| --- | --- | --- |
30+
| `AWS::SSM::Parameter` | 80 | Fast, high create rate -> most likely to throttle |
31+
| `AWS::IAM::Role` | 10 | Broadens the throttle surface to a second service |
32+
| `AWS::SNS::Topic` | 10 | Third service in the burst |
33+
34+
### DAG shape
35+
36+
- **Independent set (throttle pressure):** 70 of the 80 parameters + all 10
37+
roles + all 10 topics have NO dependencies, so they form one large ready-set
38+
the executor sheds across the `--concurrency` budget at once.
39+
- **Chained subset (DAG depth):** 10 parameters form a serial chain
40+
`ChainParam0 -> ChainParam1 -> ... -> ChainParam9`. Each `ChainParam(K)` (K>=1)
41+
embeds the previous parameter's name via `Fn::Sub`, creating an implicit Ref
42+
edge. cdkd must serialize the chain in strict order while everything else
43+
runs in parallel — a scheduling bug (dispatching a child before its parent
44+
completes) would surface as a deploy ordering failure.
45+
46+
## Automated run (`verify.sh`)
47+
48+
Env: `AWS_REGION` (default `us-east-1`), `STATE_BUCKET` (required),
49+
`CDKD_CONCURRENCY` (default `40` — intentionally high vs. the `10` default to
50+
maximise throttle pressure).
51+
52+
1. Install fixture deps (`pnpm install --ignore-workspace`).
53+
2. **Deploy** all ~100 resources with `--concurrency 40 --verbose`.
54+
- Prints any `⏳ Retrying ... / TooManyRequests / Rate exceeded / 429`
55+
activity observed (documents that the retry path was exercised when AWS
56+
throttled — throttling is probabilistic, so a clean run is also valid).
57+
- Asserts the deploy exited **0**. A non-zero exit on a throttle means the
58+
classifier did NOT retry it -> a **real finding**; the throttle error is
59+
printed.
60+
3. Asserts all resources reached AWS:
61+
- cdkd state records exactly 100 resources.
62+
- 80 SSM parameters under `/CdkdThrottleWideDagExample/` (paginated count).
63+
- The deepest chain parameter (`/.../chain/9`) holds a `child-of-...`
64+
`Fn::Sub` value -> the executor serialized the chain in DAG order.
65+
- 10 IAM roles + 10 SNS topics exist.
66+
4. **Destroy** all ~100 resources with the same high `--concurrency` and assert
67+
the delete burst also exits 0 (the destroy path must absorb ~100 deletes
68+
without throttle-failing).
69+
5. Asserts **0 orphans**: 0 SSM parameters / 0 IAM roles / 0 SNS topics remain,
70+
and the cdkd state file is gone.
71+
72+
`verify.sh` is BSD-portable (no `grep -P`, no `date -d`), captures the real
73+
deploy/destroy exit codes, and ends with an explicit PASS line.
74+
75+
Run via the skill: `/run-integ throttle-wide-dag`.
76+
77+
## Scenario tag
78+
79+
`wide-dag-throttle-retry` (see `.scenarios.json`).

0 commit comments

Comments
 (0)