AWS ratelimit fix (#796) @szhu25

* Parse PEM chain and import certificate chain

Split the PEM in certInfo.crt into a leaf certificate and intermediate chain (using a lookbehind regex), trim the blocks, and pass the chain to ImportCertificateCommand only when present. Replace console.log with this.logger.info and log the returned CertificateArn. This ensures the leaf cert is uploaded separately from its chain and avoids sending an empty CertificateChain.

* Add AWS retry & CloudFront deployment wait

Introduce robust retry and polling helpers to handle AWS throttling and CloudFront propagation. Added AwsClient.withRetry (exponential backoff, handles common throttling errors, default 5 attempts/base 2s) and waitForDistributionDeployed (polls until distribution Status is "Deployed", default 10min timeout/15s interval). Update deploy-to-cloudfront plugin to use withRetry for Get/UpdateDistribution and importCertificate, pass AwsClient into uploadToACM, and wait for each distribution to finish deploying before continuing to avoid PreconditionFailed errors. Improves reliability when facing rate limits and global CloudFront propagation delays; adds informative logging for retry and deployment status.

* fix(aws): resolve rate limit and runtime import bugs

* fix(aws): broaden retry detection and harden CloudFront poll

- withRetry: retry on Route53 PriorRequestNotComplete, SlowDown, SDK v3
  $retryable.throttling hints and HTTP 429/503; extract detection into
  isRetryableAwsError
- withRetry: add equal jitter to backoff so concurrent pipelines don't
  retry in lockstep
- waitForDistributionDeployed: a throttle that outlives withRetry's budget
  keeps polling until the deadline instead of aborting the deploy
- test: cover withRetry retry/exhaustion/non-throttle paths

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dRf6EbAmwb37cpnvgBDrh

* fix(aws): fix build break and make withRetry the sole retry authority

- withRetry<T = any>: default the type param so call sites passing an
  untyped SDK client (@aws-sdk/* is a lazyDependency with no build-time
  types) don't collapse the result to `unknown`. Also annotate the two
  result consumers as `any`. Fixes the TS2339 errors that broke the
  Docker build (aws-client.ts, plugin-deploy-to-cloudfront.ts).
- Set maxAttempts: 1 on every AWS SDK client (ACM/STS/Route53/CloudFront)
  so the SDK's own retry strategy no longer stacks on top of withRetry.
- isRetryableAwsError: broaden to cover what the SDK's `standard` strategy
  used to handle now that it's disabled - $retryable hint, HTTP 429 + any
  5xx, connection-level errors (ECONNRESET/ETIMEDOUT/EAI_AGAIN/...).
- waitForDistributionDeployed: a transient error that outlives withRetry's
  budget now retries on the next poll instead of aborting the whole deploy.
- test: cover 5xx, $retryable, and connection-error retry paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrMVmQyvQjes6t4DGjuBaD

* feat(aws): make CloudFront deploy-wait opt-in (default off)

Polling GetDistribution until Status=Deployed adds 3-8min of pipeline
wall-time per run for no functional gain - CloudFront finishes the
rollout on its own once UpdateDistribution returns 200. Gate it behind
a new "等待部署完成" switch, default false. Retry/backoff on the actual
mutating calls (GetDistributionConfig / UpdateDistribution / ACM import
/ Route53) is unaffected - it wraps those sends directly, not the poll.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrMVmQyvQjes6t4DGjuBaD

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Steven Zhu
2026-09-06 16:19:47 +08:00
committed by GitHub
co-authored by Claude Sonnet 5
parent bd9433478a
commit 1b882a2ca4
4 changed files with 282 additions and 32 deletions
@@ -35,7 +35,7 @@ async function createAwsClient(records: any[]) {
ChangeResourceRecordSetsCommand,
}),
} as any,
logger: { info() {}, error() {} } as any,
logger: { info() {}, warn() {}, error() {} } as any,
region: "us-east-1",
});
return { client, sentChanges };
@@ -92,3 +92,124 @@ describe("AwsClient.route53ChangeRecord", () => {
assert.deepEqual(change.ResourceRecordSet.ResourceRecords, [{ Value: '"wildcard-token"' }]);
});
});
function throttle(name: string, extra: Record<string, any> = {}) {
const err: any = new Error(name);
err.name = name;
return Object.assign(err, extra);
}
describe("AwsClient.withRetry", () => {
it("retries a throttled call and then resolves", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
const result = await client.withRetry(async () => {
calls++;
if (calls < 3) {
throw throttle("ThrottlingException");
}
return "ok";
});
assert.equal(result, "ok");
assert.equal(calls, 3);
});
it("retries Route53 PriorRequestNotComplete", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
const result = await client.withRetry(async () => {
calls++;
if (calls < 2) {
throw throttle("PriorRequestNotComplete");
}
return "done";
});
assert.equal(result, "done");
assert.equal(calls, 2);
});
it("retries on HTTP 429 without a known error code", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await client.withRetry(async () => {
calls++;
if (calls < 2) {
const err: any = new Error("too many requests");
err.$metadata = { httpStatusCode: 429 };
throw err;
}
return null;
});
assert.equal(calls, 2);
});
it("retries transient HTTP 5xx", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await client.withRetry(async () => {
calls++;
if (calls < 2) {
const err: any = new Error("service unavailable");
err.$metadata = { httpStatusCode: 503 };
throw err;
}
return null;
});
assert.equal(calls, 2);
});
it("retries when the SDK marks the error $retryable", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await client.withRetry(async () => {
calls++;
if (calls < 2) {
const err: any = new Error("internal failure");
err.name = "InternalFailure";
err.$retryable = {};
throw err;
}
return null;
});
assert.equal(calls, 2);
});
it("retries connection-level errors", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await client.withRetry(async () => {
calls++;
if (calls < 2) {
throw throttle("Error", { code: "ECONNRESET" });
}
return null;
});
assert.equal(calls, 2);
});
it("does not retry non-throttling errors", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await assert.rejects(
client.withRetry(async () => {
calls++;
throw throttle("AccessDeniedException");
}),
/AccessDeniedException/
);
assert.equal(calls, 1);
});
it("stops after maxAttempts and rethrows the throttling error", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await assert.rejects(
client.withRetry(async () => {
calls++;
throw throttle("Throttling", { message: "Rate exceeded" });
}, 3),
/Throttling|Rate exceeded/
);
assert.equal(calls, 3);
});
});
@@ -28,6 +28,8 @@ export class AwsClient {
accessKeyId: this.access.accessKeyId, // 从环境变量中读取
secretAccessKey: this.access.secretAccessKey,
},
// Disable the SDK's own retries; withRetry/doRequest is the single retry authority.
maxAttempts: 1,
});
// Split the full PEM chain: first block is the leaf cert, the rest is the intermediate chain
@@ -57,6 +59,7 @@ export class AwsClient {
accessKeyId: this.access.accessKeyId, // 从环境变量中读取
secretAccessKey: this.access.secretAccessKey,
},
maxAttempts: 1,
});
const command = new GetCallerIdentityCommand({});
@@ -75,6 +78,7 @@ export class AwsClient {
accessKeyId: this.access.accessKeyId, // 从环境变量中读取
secretAccessKey: this.access.secretAccessKey,
},
maxAttempts: 1,
});
}
@@ -202,10 +206,107 @@ export class AwsClient {
async doRequest<T>(call: () => Promise<T>): Promise<T> {
try {
return await call();
} catch (err) {
this.logger.error(`调用接口失败:${err.Error?.Message || err.message},requestId:${err.requestId}`);
return await this.withRetry(call);
} catch (err: any) {
this.logger.error(`请求失败:${err.Error?.Message || err.message},requestId:${err.requestId}`);
throw err;
}
}
/**
* Retries an AWS SDK call with exponential backoff + jitter while the error is
* retryable. See {@link AwsClient.isRetryableAwsError}.
*
* `T` defaults to `any` so that call sites passing an untyped SDK client
* (`@aws-sdk/*` is a lazyDependency and has no types at build time) don't
* collapse the result to `unknown`.
*/
async withRetry<T = any>(call: () => Promise<T>, maxAttempts = 5, baseDelayMs = 2000): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await call();
} catch (err: any) {
if (attempt >= maxAttempts || !AwsClient.isRetryableAwsError(err)) {
throw err;
}
const code = err?.name || err?.Code || err?.code || err?.$metadata?.httpStatusCode || "unknown";
const ceiling = baseDelayMs * Math.pow(2, attempt - 1); // 2s, 4s, 8s, 16s …
const delay = Math.round(ceiling / 2 + Math.random() * (ceiling / 2)); // equal jitter: ~1-2s, ~2-4s, ~4-8s, ~8-16s
this.logger.warn(`AWS request failed (${code}), attempt ${attempt}/${maxAttempts}, retrying in ${delay}ms…`);
await utils.sleep(delay);
}
}
throw new Error("Unreachable");
}
/**
* Detects AWS errors that are safe to retry. Because we disable the SDK's own
* retries (maxAttempts: 1 on every client), this is the single source of truth
* and covers what the SDK's `standard` strategy would have retried:
* - throttling / rate-limit codes across ACM / STS / CloudFront / Route53,
* including Route53's PriorRequestNotComplete concurrent-change limit;
* - SDK v3 retry hints (`$retryable`);
* - transient HTTP 429 and 5xx;
* - connection-level errors (ECONNRESET, ETIMEDOUT, EAI_AGAIN, …).
*/
static isRetryableAwsError(err: any): boolean {
if (!err) {
return false;
}
const retryableCodes = new Set([
"TooManyRequestsException",
"ThrottlingException",
"ThrottledException",
"RequestLimitExceeded",
"RequestThrottled",
"Throttling",
"SlowDown",
"PriorRequestNotComplete",
"RequestTimeout",
"RequestTimeoutException",
"TimeoutError",
]);
const connectionCodes = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "EHOSTUNREACH", "ENETUNREACH"]);
const code = err.name || err.Code || err.code || "";
const httpStatus = err.$metadata?.httpStatusCode;
return (
retryableCodes.has(code) ||
connectionCodes.has(err.code) ||
err.$retryable != null ||
httpStatus === 429 ||
(typeof httpStatus === "number" && httpStatus >= 500) ||
(typeof err.message === "string" && err.message.toLowerCase().includes("rate exceeded"))
);
}
/**
* Polls a CloudFront distribution until its Status becomes "Deployed".
* CloudFront propagates changes globally and can take several minutes.
*/
async waitForDistributionDeployed(cloudFrontClient: any, distributionId: string, timeoutMs = 600_000, pollIntervalMs = 15_000): Promise<void> {
const { GetDistributionCommand } = await this.access.importRuntime("@aws-sdk/client-cloudfront");
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
let status: string | undefined;
try {
const res: any = await this.withRetry(() => cloudFrontClient.send(new GetDistributionCommand({ Id: distributionId })));
status = res?.Distribution?.Status;
} catch (err: any) {
// A retryable error that outlives withRetry's budget shouldn't abort the whole deploy;
// keep polling until the deadline and let genuine (non-retryable) errors propagate.
if (!AwsClient.isRetryableAwsError(err)) {
throw err;
}
this.logger.warn(`CloudFront status check failed (${err?.name || err?.code}), will retry on next poll…`);
await utils.sleep(pollIntervalMs);
continue;
}
this.logger.info(`CloudFront distribution ${distributionId} status: ${status}`);
if (status === "Deployed") {
return;
}
await utils.sleep(pollIntervalMs);
}
throw new Error(`Timed out waiting for CloudFront distribution ${distributionId} to reach Deployed status`);
}
}
@@ -67,15 +67,35 @@ export class AwsDeployToCloudFront extends AbstractTaskPlugin {
)
distributionIds!: string[];
@TaskInput({
title: "等待部署完成",
value: false,
helper:
"开启后,会轮询等待每个CloudFront分配状态变为Deployed(通常需要几分钟)再结束任务。" +
"关闭时,提交证书更新后立即结束——CloudFront会在后台自行完成部署。" +
"仅当后续任务依赖证书已全球生效时才需要开启。",
component: {
name: "a-switch",
vModel: "checked",
},
})
waitForDeployed = false;
async onInstance() {}
async execute(): Promise<void> {
const access = await this.getAccess<AwsAccess>(this.accessId);
const acmClient = new AwsClient({
access,
region: this.region,
logger: this.logger,
});
let certId = this.cert as string;
if (typeof this.cert !== "string") {
//先上传
certId = await this.uploadToACM(access, this.cert);
certId = await this.uploadToACM(acmClient, this.cert);
}
//部署到CloudFront
@@ -86,42 +106,49 @@ export class AwsDeployToCloudFront extends AbstractTaskPlugin {
accessKeyId: access.accessKeyId,
secretAccessKey: access.secretAccessKey,
},
// Disable the SDK's own retries; acmClient.withRetry is the single retry authority.
maxAttempts: 1,
});
// update-distribution
for (const distributionId of this.distributionIds) {
// get-distribution-config
const getDistributionConfigCommand = new GetDistributionConfigCommand({
Id: distributionId,
});
// get-distribution-config (with retry for throttling)
const configData: any = await acmClient.withRetry(() =>
cloudFrontClient.send(new GetDistributionConfigCommand({ Id: distributionId }))
);
const configData = await cloudFrontClient.send(getDistributionConfigCommand);
await acmClient.withRetry(() =>
cloudFrontClient.send(
new UpdateDistributionCommand({
DistributionConfig: {
...configData.DistributionConfig,
ViewerCertificate: {
...configData.DistributionConfig.ViewerCertificate,
CloudFrontDefaultCertificate: false,
ACMCertificateArn: certId,
},
},
Id: distributionId,
IfMatch: configData.ETag,
})
)
);
const updateDistributionCommand = new UpdateDistributionCommand({
DistributionConfig: {
...configData.DistributionConfig,
ViewerCertificate: {
...configData.DistributionConfig.ViewerCertificate,
CloudFrontDefaultCertificate: false,
ACMCertificateArn: certId,
},
},
Id: distributionId,
IfMatch: configData.ETag,
});
await cloudFrontClient.send(updateDistributionCommand);
this.logger.info(`部署${distributionId}完成:`);
if (this.waitForDeployed) {
this.logger.info(`证书已提交到 ${distributionId},等待全局部署完成…`);
// CloudFront propagates globally in a few minutes; only block when the
// user opts in (e.g. a downstream task needs the cert already live).
await acmClient.waitForDistributionDeployed(cloudFrontClient, distributionId);
this.logger.info(`部署 ${distributionId} 完成`);
} else {
this.logger.info(`证书已提交到 ${distributionId},CloudFront 将在后台完成部署`);
}
}
this.logger.info("部署完成");
}
private async uploadToACM(access: AwsAccess, cert: CertInfo) {
const acmClient = new AwsClient({
access,
region: this.region,
logger: this.logger,
});
const awsCertARN = await acmClient.importCertificate(cert);
private async uploadToACM(acmClient: AwsClient, cert: CertInfo) {
const awsCertARN = await acmClient.withRetry(() => acmClient.importCertificate(cert));
this.logger.info("证书上传成功,id=", awsCertARN);
return awsCertARN;
}
@@ -140,6 +167,7 @@ export class AwsDeployToCloudFront extends AbstractTaskPlugin {
accessKeyId: access.accessKeyId,
secretAccessKey: access.secretAccessKey,
},
maxAttempts: 1,
});
// list-distributions
const listDistributionsCommand = new ListDistributionsCommand({});
@@ -65,7 +65,7 @@ export class AwsUploadToACM extends AbstractTaskPlugin {
region,
logger: this.logger,
});
this.awsCertARN = await acmClient.importCertificate(cert);
this.awsCertARN = await acmClient.withRetry(() => acmClient.importCertificate(cert));
this.logger.info("证书上传成功,id=", this.awsCertARN);
}
}