fix(amplify-category-api-e2e-tests): guard getS3Buckets so one dead region can't abort cleanup - #3522
fix(amplify-category-api-e2e-tests): guard getS3Buckets so one dead region can't abort cleanup#3522sarayev wants to merge 2 commits into
Conversation
…egion can't abort cleanup
The scheduled amplify-category-api-cleanup-workflow job failed 5/5 consecutive runs, leaving
every e2e account uncleaned.
The per-region STS/CloudFormation/RDS listing calls already skip and continue on failure, but
the S3 sweep did not. When getS3Buckets reached the hard-down me-south-1 region, the per-bucket
catch fell through to `throw e` after logging "Irrecoverable error in getS3Buckets {code:
ETIMEDOUT}". That rejection propagated through cleanupAccount's Promise.all, so a single
unreachable region aborted the entire run for all ~13 e2e accounts. Stacks then accumulated
until accounts hit the 2000-stack CloudFormation quota (L-0485CB21), which made the CreateApi
canary hang and fire alarms.
A bucket lives in exactly one region, so a region-level failure can only ever mean "this bucket
is unprocessable right now"; it is never a reason to abandon the remaining accounts. This
mirrors the existing skip-and-continue pattern (getOrphanRdsInstances, getAmplifyApps,
listStacks) for the S3 sweep:
- getS3Buckets: a per-bucket failure that is not NoSuchTagSet/NoSuchBucket/InvalidToken is now
logged as an "(opt-in region failure) ... Skipping." and the sweep continues.
- getOrphanS3TestBuckets: region resolution is guarded per bucket, so one bucket can no longer
reject the surrounding Promise.all.
- Both ListBuckets calls now go through a shared listStaleTestBuckets helper, keeping an
account-level listing failure scoped to that account.
The set of swept regions is deliberately unchanged.
Adds unit tests for the regression, plus a jest config and `test` script so they run in CI
without loading the AWS-backed e2e runner. The script entry point is now behind
`require.main === module` so tests can import the module without deleting real resources.
Refs: P492565382
|
Heads-up for reviewers building this locally: I verified this is not introduced by this PR: pristine To run the new tests: |
| } catch (e) { | ||
| console.log( | ||
| `(opt-in region failure) Listing S3 buckets for account ${account.accountId} failed with error with code ${ | ||
| e?.name ?? e?.code |
There was a problem hiding this comment.
**🟢 Minor
e?.name ?? e?.code resolves to name first, but a plain socket error like this ticket's ETIMEDOUT has name === 'Error' (truthy), so ?? never reaches .code and the log reads code Error instead of code ETIMEDOUT — swallowing the one detail this guard exists to catch. Flipping to e?.code ?? e?.name (or logging both, ${e?.name}/${e?.code}) fixes it. Here and in getOrphanS3TestBuckets there's no JSON.stringify(e) either, so unlike getS3Buckets the stack is lost too — worth including it so a persistent failure is diagnosable.
There was a problem hiding this comment.
Good catch — you're right, and this was a real defect in the guard's observability. Fixed in e22d3c15c.
I verified the claim before changing anything:
| error | name |
code |
name ?? code (old) |
code ?? name (new) |
|---|---|---|---|---|
socket ETIMEDOUT |
Error |
ETIMEDOUT |
Error ❌ |
ETIMEDOUT ✅ |
SDK InvalidClientTokenId |
InvalidClientTokenId |
undefined |
InvalidClientTokenId |
InvalidClientTokenId ✅ |
Exactly as you described: ?? only falls through on null/undefined, and name is the truthy string 'Error', so the ticket's ETIMEDOUT — the one detail this guard exists to surface — never made it into the log. I went with e?.code ?? e?.name rather than logging both, since the sentence already reads "…with code X" and, per the table, AWS SDK service errors carry no code, so the existing named paths (InvalidClientTokenId, NoSuchTagSet, InvalidToken) log character-for-character as they did before. Applied at all three sites: listStaleTestBuckets, the per-bucket catch in getOrphanS3TestBuckets, and the per-bucket catch in getS3Buckets.
On the serialized error — agreed the stack should be there, but I deliberately did not use JSON.stringify(e), because it would not actually have given you the stack:
JSON.stringify(socketError)
// {"errno":-110,"code":"ETIMEDOUT","syscall":"connect","address":"52.95.128.1","port":443}
message and stack are non-enumerable own properties of Error, so JSON.stringify silently drops both — which is the same class of "the useful detail is quietly gone" problem you flagged in the first place. Instead I pass the caught error as the second console.log argument, so Node's formatter prints the full stack and the enumerable system-error fields. That also means getOrphanS3TestBuckets, which previously logged no error at all, now does. Happy to switch to literal JSON.stringify(e) for consistency with the neighbouring CFN/RDS guards if you'd rather keep the style uniform — just say so and I'll push it.
The tests now pin the ordering so this can't silently regress. Each timeout test asserts both the rendered code and identity of the logged error:
expect(skipLog.message).toContain('failed with error with code ETIMEDOUT');
expect(skipLog.loggedError).toBe(thrown); // the real Error, stack intact — not a stringified huskskipLogFor() throws a descriptive failure if no matching skip log was emitted, so these can't pass vacuously. Confirmed non-vacuous by reverting only the operand order: 5 of the 6 tests fail, each with failed with error with code Error. The sixth (NoSuchTagSet/InvalidToken) keeps passing, which is the evidence that the flip doesn't regress the SDK-named paths.
6 passed, 6 total with the fix in place; prettier clean; eslint unchanged from the pre-existing baseline (7 errors, all outside the edited regions).
…kipping an S3 region PR feedback: the skip logs resolved the error label with `e?.name ?? e?.code`, but a plain socket error carries `name === 'Error'` and `code === 'ETIMEDOUT'`. Since `??` only falls through on null/undefined, it always picked the useless `Error` and dropped the ETIMEDOUT detail these guards exist to surface. Prefer `code`, fall back to `name`; AWS SDK service errors have no `code`, so their named paths log exactly as before. Also log the caught error object itself rather than JSON.stringify(e): `message` and `stack` are non-enumerable on Error, so stringifying drops the stack that makes a persistent failure diagnosable.
Description of changes
The scheduled
amplify-category-api-cleanup-workflowjob (cd packages/amplify-e2e-tests && yarn clean-e2e-resources) had failed 5/5 consecutive runs, leaving every e2e account uncleaned.The per-region STS / CloudFormation / RDS listing calls in
cleanup-e2e-resources.tsare already wrapped in skip-and-continue error handling, but the S3 sweep was not. WhengetS3Bucketsreached the hard-downme-south-1region, the per-bucketcatchfell through tothrow eafter loggingIrrecoverable error in getS3Buckets {code: ETIMEDOUT}. That rejection propagated up throughcleanupAccount'sPromise.all, so one unreachable region aborted the entire cleanup run for all ~13 e2e accounts. Stacks then accumulated until accounts hit the 2000-stack CloudFormation quota (L-0485CB21), which made the CreateApi canary hang (LimitExceededExceptionswallowed by the CLI → 1800s watchdog kill) and fire alarms.A bucket lives in exactly one region, so a region-level failure can only ever mean "this bucket is unprocessable right now" — it is never a reason to abandon the remaining accounts. This change mirrors the existing skip-and-continue pattern already used by
getOrphanRdsInstances,getAmplifyAppsandlistStacks(same(opt-in region failure) ... Skipping.log style) and applies it to the S3 sweep:getS3Buckets— a per-bucket failure that is notNoSuchTagSet/NoSuchBucket/InvalidTokenis now logged as a skip and the sweep continues, instead of rethrowing. The existing handling of those three cases is unchanged.getOrphanS3TestBuckets— bucket region resolution is guarded per bucket, so one bucket can no longer reject the surroundingPromise.all.listStaleTestBuckets(new, shared by both) — guards the previously unprotected top-levelListBucketscall so an account-level listing failure stays scoped to that account.require.main === module, so the module can be imported by unit tests without deleting real resources.yarn clean-e2e-resourcesbehaviour is unchanged (verified below).Deliberately not in scope: the set of swept regions is unchanged (removing
me-south-1is a separate decision), and no other cleanup behaviour was touched.CDK / CloudFormation Parameters Changed
None.
Issue #, if available
Internal oncall ticket P492565382.
Description of how you validated changes
Added
packages/amplify-e2e-tests/src/__unit_tests__/cleanup-e2e-resources.test.ts(6 tests), which mocks the@aws-sdk/client-s3boundary and simulatesme-south-1failing with the exact ticket error (connect ETIMEDOUT,code: ETIMEDOUT, anamematching none of the specially handled S3 error names).The tests are proven non-vacuous: reverting only the
getS3Bucketsguard back tothrow e(keeping everything else) makes exactly the two fault-isolation tests fail withconnect ETIMEDOUT, reproducing the aborted run. Restoring the guard makes them pass again.These tests needed a jest config that does not load the package's AWS-backed e2e runner/environment/global-setup, so
jest.unit.config.jswas added along with atestscript (the package previously had none, soyarn test-ciskipped it entirely — these tests now actually run in CI).src/__unit_tests__is excluded from the e2e jest config soyarn e2eis unaffected.Also verified:
prettier --checkclean on all four changed files.eslintintroduces zero new errors incleanup-e2e-resources.ts(error set is byte-identical toorigin/mainapart from a line-number shift in a pre-existingno-shadowerror); the new test file is eslint-clean.ts-node ./src/cleanup-e2e-resources.ts --helpexits 0 and prints the yargs help, confirmingcleanup()is still invoked when run as a script.Checklist
yarn testpassesBy submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.