1import * as ag from "@distilled.cloud/aws/api-gateway";
2import * as Retry from "@distilled.cloud/aws/Retry";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Semaphore from "effect/Semaphore";
7import { diffTags, normalizeTags } from "../../Tags.ts";
8
9/**
10 * API Gateway serializes mutations to a single RestApi at the control-plane
11 * level: while the API is transitioning between states it responds with a
12 * `BadRequestException` whose message matches one of:
13 *
14 * - `You cannot deploy a RestApi while the apiStatus is UPDATING or FAILED.`
15 * - `There is already an update in progress.`
16 *
17 * These are fundamentally transient — the retry window is short (seconds)
18 * and the only correct response is to wait and try again. Because the
19 * exception is a 4xx, the generic `Retry.transient` policy applied to the
20 * AWS SDK will not retry it, so we apply a targeted retry here for
21 * operations that race with concurrent mutations on the same RestApi
22 * (typically `createDeployment`, `updateStage`, `deleteStage`).
23 */
24const isApiStatusUpdatingError = (error: unknown): boolean => {
25 if (!error || typeof error !== "object") return false;
26 if ((error as { _tag?: string })._tag !== "BadRequestException") return false;
27 const message = (error as { message?: string }).message ?? "";
28 return (
29 message.includes("apiStatus is UPDATING") ||
30 message.includes("already an update in progress")
31 );
32};
33
34/**
35 * Schedule for API Gateway's transient API-status conflicts. Throttling is
36 * deliberately not retried here: the shared AWS client policy already has a
37 * bounded, capped retry window long enough to cross API Gateway's ~30-second
38 * mutation quota. Retrying throttling again at this layer would nest the two
39 * policies and turn one operation into a many-minute wait.
40 */
41const apiGatewayMutationSchedule = Schedule.max([
42 Schedule.spaced("5 seconds"),
43 Schedule.recurs(10),
44]);
45
46/**
47 * Wraps an API Gateway mutation so that recoverable 4xx responses are
48 * retried with backoff:
49 *
50 * - `BadRequestException` with `apiStatus is UPDATING` or
51 * `already an update in progress` (transient, clears in seconds)
52 *
53 * Drop-in usage:
54 *
55 * ```ts
56 * yield* retryOnApiStatusUpdating(
57 * ag.createDeployment({ ... }),
58 * );
59 * ```
60 */
61export const retryOnApiStatusUpdating = <A, E, R>(
62 effect: Effect.Effect<A, E, R>,
63): Effect.Effect<A, E, R> =>
64 Effect.retry(effect, {
65 schedule: apiGatewayMutationSchedule,
66 while: isApiStatusUpdatingError,
67 }) as Effect.Effect<A, E, R>;
68
69const isRestApiDeleteRetryable = (error: unknown): boolean => {
70 if (!error || typeof error !== "object") return false;
71 const tagged = error as { _tag?: string; message?: string };
72 return (
73 tagged._tag === "TooManyRequestsException" ||
74 tagged._tag === "ConflictException" ||
75 (tagged._tag === "BadRequestException" &&
76 (tagged.message?.includes("apiStatus is UPDATING") === true ||
77 tagged.message?.includes("already an update in progress") === true))
78 );
79};
80
81// DeleteRestApi has its own unusually low regional quota (roughly one request
82// every 30 seconds). Override the blanket AWS retry layer for this operation:
83// otherwise its 5-second-capped budget expires after about a minute, and then
84// wrapping that exhausted call in another retry schedule creates an opaque,
85// nested multi-minute wait. This is one explicit 90-second wall instead.
86const restApiDeleteSchedule = Schedule.max([
87 Schedule.spaced("5 seconds"),
88 Schedule.recurs(18),
89]);
90
91class RestApiStillExists extends Data.TaggedError("RestApiStillExists")<{
92 readonly restApiId: string;
93}> {}
94
95const restApiGoneSchedule = Schedule.max([
96 Schedule.spaced("1 second"),
97 Schedule.recurs(8),
98]);
99
100// DeleteRestApi's ~1-per-30s quota is ACCOUNT-wide, so concurrent destroys
101// (parallel test files, parallel stacks in one process) all contend for the
102// same token — each one's individual retry wall can expire while the others
103// keep winning the race. Serialize deletes process-wide so each waiter only
104// contends with the quota window itself, never with its siblings.
105const restApiDeleteMutex = Semaphore.makeUnsafe(1);
106
107/**
108 * Idempotently delete a REST API and observe it disappear.
109 *
110 * Every provider/test cleanup path uses this helper so nuke, normal destroy,
111 * and interrupted-run reapers all share the same bounded throttle handling.
112 */
113export const deleteRestApiAndWait = Effect.fn(function* (restApiId: string) {
114 yield* ag.deleteRestApi({ restApiId }).pipe(
115 Retry.policy({
116 while: isRestApiDeleteRetryable,
117 schedule: restApiDeleteSchedule,
118 }),
119 Effect.catchTag("NotFoundException", () => Effect.void),
120 Semaphore.withPermits(restApiDeleteMutex, 1),
121 );
122
123 // Avoid nesting the account-wide AWS retry policy inside the observable
124 // poll. Each getRestApi is single-shot; this one bounded schedule owns both
125 // throttled reads and the short eventual-consistency window.
126 const observe = Retry.none(ag.getRestApi({ restApiId }));
127 yield* observe.pipe(
128 Effect.flatMap(() => Effect.fail(new RestApiStillExists({ restApiId }))),
129 Effect.retry({
130 while: (error) =>
131 error._tag === "TooManyRequestsException" ||
132 error._tag === "RestApiStillExists",
133 schedule: restApiGoneSchedule,
134 }),
135 Effect.catchTag("NotFoundException", () => Effect.void),
136 );
137});
138
139export const restApiArn = (region: string, restApiId: string) =>
140 `arn:aws:apigateway:${region}::/restapis/${restApiId}`;
141
142export const stageArn = (
143 region: string,
144 restApiId: string,
145 stageName: string,
146) => `arn:aws:apigateway:${region}::/restapis/${restApiId}/stages/${stageName}`;
147
148export const apiKeyArn = (region: string, apiKeyId: string) =>
149 `arn:aws:apigateway:${region}::/apikeys/${apiKeyId}`;
150
151export const usagePlanArn = (region: string, usagePlanId: string) =>
152 `arn:aws:apigateway:${region}::/usageplans/${usagePlanId}`;
153
154export const domainNameArn = (region: string, domainName: string) =>
155 `arn:aws:apigateway:${region}::/domainnames/${domainName}`;
156
157export const vpcLinkArn = (region: string, vpcLinkId: string) =>
158 `arn:aws:apigateway:${region}::/vpclinks/${vpcLinkId}`;
159
160export const syncTags = Effect.fn(function* ({
161 resourceArn,
162 oldTags,
163 newTags,
164}: {
165 resourceArn: string;
166 oldTags: Record<string, string>;
167 newTags: Record<string, string>;
168}) {
169 const { removed, upsert } = diffTags(oldTags, newTags);
170 if (removed.length > 0) {
171 yield* ag
172 .untagResource({
173 resourceArn,
174 tagKeys: removed,
175 })
176 .pipe(
177 Effect.catchTag("NotFoundException", () => Effect.void),
178 Effect.catchTag("BadRequestException", () => Effect.void),
179 );
180 }
181 if (upsert.length > 0) {
182 yield* ag.tagResource({
183 resourceArn,
184 tags: normalizeTags(upsert),
185 });
186 }
187});