| 1 | import * as ag from "@distilled.cloud/aws/api-gateway"; |
| 2 | import * as Retry from "@distilled.cloud/aws/Retry"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import * as Semaphore from "effect/Semaphore"; |
| 7 | import { 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 | */ |
| 24 | const 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 | */ |
| 41 | const 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 | */ |
| 61 | export 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 | |
| 69 | const 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. |
| 86 | const restApiDeleteSchedule = Schedule.max([ |
| 87 | Schedule.spaced("5 seconds"), |
| 88 | Schedule.recurs(18), |
| 89 | ]); |
| 90 | |
| 91 | class RestApiStillExists extends Data.TaggedError("RestApiStillExists")<{ |
| 92 | readonly restApiId: string; |
| 93 | }> {} |
| 94 | |
| 95 | const 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. |
| 105 | const 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 | */ |
| 113 | export 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 | |
| 139 | export const restApiArn = (region: string, restApiId: string) => |
| 140 | `arn:aws:apigateway:${region}::/restapis/${restApiId}`; |
| 141 | |
| 142 | export const stageArn = ( |
| 143 | region: string, |
| 144 | restApiId: string, |
| 145 | stageName: string, |
| 146 | ) => `arn:aws:apigateway:${region}::/restapis/${restApiId}/stages/${stageName}`; |
| 147 | |
| 148 | export const apiKeyArn = (region: string, apiKeyId: string) => |
| 149 | `arn:aws:apigateway:${region}::/apikeys/${apiKeyId}`; |
| 150 | |
| 151 | export const usagePlanArn = (region: string, usagePlanId: string) => |
| 152 | `arn:aws:apigateway:${region}::/usageplans/${usagePlanId}`; |
| 153 | |
| 154 | export const domainNameArn = (region: string, domainName: string) => |
| 155 | `arn:aws:apigateway:${region}::/domainnames/${domainName}`; |
| 156 | |
| 157 | export const vpcLinkArn = (region: string, vpcLinkId: string) => |
| 158 | `arn:aws:apigateway:${region}::/vpclinks/${vpcLinkId}`; |
| 159 | |
| 160 | export 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 | }); |