import * as ag from "@distilled.cloud/aws/api-gateway";
import * as Retry from "@distilled.cloud/aws/Retry";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import * as Semaphore from "effect/Semaphore";
import { diffTags, normalizeTags } from "../../Tags.ts";
/**
* API Gateway serializes mutations to a single RestApi at the control-plane
* level: while the API is transitioning between states it responds with a
* `BadRequestException` whose message matches one of:
*
* - `You cannot deploy a RestApi while the apiStatus is UPDATING or FAILED.`
* - `There is already an update in progress.`
*
* These are fundamentally transient — the retry window is short (seconds)
* and the only correct response is to wait and try again. Because the
* exception is a 4xx, the generic `Retry.transient` policy applied to the
* AWS SDK will not retry it, so we apply a targeted retry here for
* operations that race with concurrent mutations on the same RestApi
* (typically `createDeployment`, `updateStage`, `deleteStage`).
*/
const isApiStatusUpdatingError = (error: unknown): boolean => {
if (!error || typeof error !== "object") return false;
if ((error as { _tag?: string })._tag !== "BadRequestException") return false;
const message = (error as { message?: string }).message ?? "";
return (
message.includes("apiStatus is UPDATING") ||
message.includes("already an update in progress")
);
};
/**
* Schedule for API Gateway's transient API-status conflicts. Throttling is
* deliberately not retried here: the shared AWS client policy already has a
* bounded, capped retry window long enough to cross API Gateway's ~30-second
* mutation quota. Retrying throttling again at this layer would nest the two
* policies and turn one operation into a many-minute wait.
*/
const apiGatewayMutationSchedule = Schedule.max([
Schedule.spaced("5 seconds"),
Schedule.recurs(10),
]);
/**
* Wraps an API Gateway mutation so that recoverable 4xx responses are
* retried with backoff:
*
* - `BadRequestException` with `apiStatus is UPDATING` or
* `already an update in progress` (transient, clears in seconds)
*
* Drop-in usage:
*
* ```ts
* yield* retryOnApiStatusUpdating(
* ag.createDeployment({ ... }),
* );
* ```
*/
export const retryOnApiStatusUpdating = (
effect: Effect.Effect,
): Effect.Effect =>
Effect.retry(effect, {
schedule: apiGatewayMutationSchedule,
while: isApiStatusUpdatingError,
}) as Effect.Effect;
const isRestApiDeleteRetryable = (error: unknown): boolean => {
if (!error || typeof error !== "object") return false;
const tagged = error as { _tag?: string; message?: string };
return (
tagged._tag === "TooManyRequestsException" ||
tagged._tag === "ConflictException" ||
(tagged._tag === "BadRequestException" &&
(tagged.message?.includes("apiStatus is UPDATING") === true ||
tagged.message?.includes("already an update in progress") === true))
);
};
// DeleteRestApi has its own unusually low regional quota (roughly one request
// every 30 seconds). Override the blanket AWS retry layer for this operation:
// otherwise its 5-second-capped budget expires after about a minute, and then
// wrapping that exhausted call in another retry schedule creates an opaque,
// nested multi-minute wait. This is one explicit 90-second wall instead.
const restApiDeleteSchedule = Schedule.max([
Schedule.spaced("5 seconds"),
Schedule.recurs(18),
]);
class RestApiStillExists extends Data.TaggedError("RestApiStillExists")<{
readonly restApiId: string;
}> {}
const restApiGoneSchedule = Schedule.max([
Schedule.spaced("1 second"),
Schedule.recurs(8),
]);
// DeleteRestApi's ~1-per-30s quota is ACCOUNT-wide, so concurrent destroys
// (parallel test files, parallel stacks in one process) all contend for the
// same token — each one's individual retry wall can expire while the others
// keep winning the race. Serialize deletes process-wide so each waiter only
// contends with the quota window itself, never with its siblings.
const restApiDeleteMutex = Semaphore.makeUnsafe(1);
/**
* Idempotently delete a REST API and observe it disappear.
*
* Every provider/test cleanup path uses this helper so nuke, normal destroy,
* and interrupted-run reapers all share the same bounded throttle handling.
*/
export const deleteRestApiAndWait = Effect.fn(function* (restApiId: string) {
yield* ag.deleteRestApi({ restApiId }).pipe(
Retry.policy({
while: isRestApiDeleteRetryable,
schedule: restApiDeleteSchedule,
}),
Effect.catchTag("NotFoundException", () => Effect.void),
Semaphore.withPermits(restApiDeleteMutex, 1),
);
// Avoid nesting the account-wide AWS retry policy inside the observable
// poll. Each getRestApi is single-shot; this one bounded schedule owns both
// throttled reads and the short eventual-consistency window.
const observe = Retry.none(ag.getRestApi({ restApiId }));
yield* observe.pipe(
Effect.flatMap(() => Effect.fail(new RestApiStillExists({ restApiId }))),
Effect.retry({
while: (error) =>
error._tag === "TooManyRequestsException" ||
error._tag === "RestApiStillExists",
schedule: restApiGoneSchedule,
}),
Effect.catchTag("NotFoundException", () => Effect.void),
);
});
export const restApiArn = (region: string, restApiId: string) =>
`arn:aws:apigateway:${region}::/restapis/${restApiId}`;
export const stageArn = (
region: string,
restApiId: string,
stageName: string,
) => `arn:aws:apigateway:${region}::/restapis/${restApiId}/stages/${stageName}`;
export const apiKeyArn = (region: string, apiKeyId: string) =>
`arn:aws:apigateway:${region}::/apikeys/${apiKeyId}`;
export const usagePlanArn = (region: string, usagePlanId: string) =>
`arn:aws:apigateway:${region}::/usageplans/${usagePlanId}`;
export const domainNameArn = (region: string, domainName: string) =>
`arn:aws:apigateway:${region}::/domainnames/${domainName}`;
export const vpcLinkArn = (region: string, vpcLinkId: string) =>
`arn:aws:apigateway:${region}::/vpclinks/${vpcLinkId}`;
export const syncTags = Effect.fn(function* ({
resourceArn,
oldTags,
newTags,
}: {
resourceArn: string;
oldTags: Record;
newTags: Record;
}) {
const { removed, upsert } = diffTags(oldTags, newTags);
if (removed.length > 0) {
yield* ag
.untagResource({
resourceArn,
tagKeys: removed,
})
.pipe(
Effect.catchTag("NotFoundException", () => Effect.void),
Effect.catchTag("BadRequestException", () => Effect.void),
);
}
if (upsert.length > 0) {
yield* ag.tagResource({
resourceArn,
tags: normalizeTags(upsert),
});
}
});