1import * as ag from "@distilled.cloud/aws/api-gateway";
2import * as Data from "effect/Data";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6
7/**
8 * Post-destroy "assert gone" helpers shared by the ApiGateway suite.
9 *
10 * Each helper reads the resource out-of-band via distilled and succeeds only
11 * once the API answers with the typed `NotFoundException`. A resource that is
12 * still visible after the bounded retry window fails the test with
13 * `ResourceStillExists` — proving the trailing `stack.destroy()` actually
14 * deleted everything (zero orphans).
15 */
16export class ResourceStillExists extends Data.TaggedError(
17 "ResourceStillExists",
18)<{
19 readonly resource: string;
20}> {}
21
22// Deletion is effectively read-your-write for API Gateway, but reads are
23// also subject to the account-wide throttle during a busy suite — retry
24// both "still visible" and `TooManyRequestsException` briefly.
25const goneSchedule = Schedule.max([
26 Schedule.exponential(500).pipe(
27 Schedule.modifyDelay(({ duration }) =>
28 Effect.succeed(
29 Duration.isGreaterThan(duration, Duration.seconds(5))
30 ? Duration.seconds(5)
31 : duration,
32 ),
33 ),
34 ),
35 // 0.5 + 1 + 2 + 4 + five 5s delays = 32.5s, long enough to cross the
36 // account's ~30s read throttle while remaining inside the retry budget.
37 Schedule.recurs(9),
38]);
39
40export const assertRestApiDeleted = Effect.fn(function* (restApiId: string) {
41 yield* ag.getRestApi({ restApiId }).pipe(
42 Effect.flatMap(() =>
43 Effect.fail(
44 new ResourceStillExists({ resource: `RestApi ${restApiId}` }),
45 ),
46 ),
47 Effect.retry({
48 while: (e): boolean =>
49 e._tag === "ResourceStillExists" ||
50 e._tag === "TooManyRequestsException",
51 schedule: goneSchedule,
52 }),
53 Effect.catchTag("NotFoundException", () => Effect.void),
54 );
55});
56
57export const assertApiKeyDeleted = Effect.fn(function* (apiKeyId: string) {
58 yield* ag.getApiKey({ apiKey: apiKeyId }).pipe(
59 Effect.flatMap(() =>
60 Effect.fail(new ResourceStillExists({ resource: `ApiKey ${apiKeyId}` })),
61 ),
62 Effect.retry({
63 while: (e): boolean =>
64 e._tag === "ResourceStillExists" ||
65 e._tag === "TooManyRequestsException",
66 schedule: goneSchedule,
67 }),
68 Effect.catchTag("NotFoundException", () => Effect.void),
69 );
70});
71
72export const assertUsagePlanDeleted = Effect.fn(function* (
73 usagePlanId: string,
74) {
75 yield* ag.getUsagePlan({ usagePlanId }).pipe(
76 Effect.flatMap(() =>
77 Effect.fail(
78 new ResourceStillExists({ resource: `UsagePlan ${usagePlanId}` }),
79 ),
80 ),
81 Effect.retry({
82 while: (e): boolean =>
83 e._tag === "ResourceStillExists" ||
84 e._tag === "TooManyRequestsException",
85 schedule: goneSchedule,
86 }),
87 Effect.catchTag("NotFoundException", () => Effect.void),
88 );
89});
90
91export const assertDomainNameDeleted = Effect.fn(function* (
92 domainName: string,
93) {
94 yield* ag.getDomainName({ domainName }).pipe(
95 Effect.flatMap(() =>
96 Effect.fail(
97 new ResourceStillExists({ resource: `DomainName ${domainName}` }),
98 ),
99 ),
100 Effect.retry({
101 while: (e): boolean =>
102 e._tag === "ResourceStillExists" ||
103 e._tag === "TooManyRequestsException",
104 schedule: goneSchedule,
105 }),
106 Effect.catchTag("NotFoundException", () => Effect.void),
107 );
108});
109
110export const assertVpcLinkDeleted = Effect.fn(function* (vpcLinkId: string) {
111 // A VPC link lingers in DELETING for several minutes after deleteVpcLink;
112 // reaching that status is the terminal signal deletion was accepted.
113 yield* ag.getVpcLink({ vpcLinkId }).pipe(
114 Effect.flatMap((link) =>
115 link.status === "DELETING"
116 ? Effect.void
117 : Effect.fail(
118 new ResourceStillExists({ resource: `VpcLink ${vpcLinkId}` }),
119 ),
120 ),
121 Effect.retry({
122 while: (e): boolean =>
123 e._tag === "ResourceStillExists" ||
124 e._tag === "TooManyRequestsException",
125 schedule: goneSchedule,
126 }),
127 Effect.catchTag("NotFoundException", () => Effect.void),
128 );
129});