1import * as Hetzner from "@distilled.cloud/hetzner";
2import type { GetActionResponseAction } from "@distilled.cloud/hetzner/actions";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7
8export class ActionPending extends Data.TaggedError("ActionPending")<{
9 actionId: number;
10 status: string;
11}> {}
12
13export class ActionFailed extends Data.TaggedError("ActionFailed")<{
14 actionId: number;
15 command: string;
16 code?: string;
17 message: string;
18}> {}
19
20export class ActionTimeout extends Data.TaggedError("ActionTimeout")<{
21 actionId: number;
22 status: string;
23}> {}
24
25export type ActionRef =
26 | number
27 | Pick<GetActionResponseAction, "id">
28 | GetActionResponseAction;
29
30const actionIdOf = (ref: ActionRef): number =>
31 typeof ref === "number" ? ref : ref.id;
32
33const isErrorStatus = (status: string): boolean => status === "error";
34const isSuccessStatus = (status: string): boolean => status === "success";
35
36const failIfError = (action: GetActionResponseAction) => {
37 if (isErrorStatus(action.status)) {
38 return Effect.fail(
39 new ActionFailed({
40 actionId: action.id,
41 command: action.command,
42 code: action.error?.code,
43 message: action.error?.message ?? "Hetzner Action failed",
44 }),
45 );
46 }
47 return Effect.void;
48};
49
50const retryable = (e: { readonly _tag: string }): boolean =>
51 e._tag === "ActionPending" ||
52 e._tag === "TooManyRequests" ||
53 e._tag === "ServiceUnavailable" ||
54 e._tag === "InternalServerError" ||
55 e._tag === "BadGateway" ||
56 e._tag === "GatewayTimeout";
57
58const backoff = Schedule.min([
59 Schedule.exponential(Duration.millis(500), 1.5),
60 Schedule.spaced(Duration.seconds(5)),
61]);
62
63/**
64 * Poll a Hetzner Action until it reaches `success` or `error`.
65 *
66 * Bounded: at most 24 polls, exponential backoff starting at 500ms and
67 * capped at 5s. Already-finished actions return immediately.
68 */
69export const waitForAction = (
70 ref: ActionRef,
71): Effect.Effect<
72 GetActionResponseAction,
73 ActionFailed | ActionTimeout | Hetzner.actions.GetActionError,
74 Hetzner.actions.HetznerOpContext
75> =>
76 Effect.gen(function* () {
77 if (typeof ref !== "number" && "status" in ref) {
78 yield* failIfError(ref);
79 if (isSuccessStatus(ref.status)) {
80 return ref;
81 }
82 }
83
84 const id = actionIdOf(ref);
85 return yield* Hetzner.actions.getAction({ id }).pipe(
86 Effect.flatMap(({ action }) =>
87 Effect.gen(function* () {
88 yield* failIfError(action);
89 if (!isSuccessStatus(action.status)) {
90 return yield* new ActionPending({
91 actionId: action.id,
92 status: action.status,
93 });
94 }
95 return action;
96 }),
97 ),
98 Effect.retry({
99 while: retryable,
100 times: 24,
101 schedule: backoff,
102 }),
103 Effect.catchTag(
104 "ActionPending",
105 (e) =>
106 new ActionTimeout({
107 actionId: e.actionId,
108 status: e.status,
109 }),
110 ),
111 );
112 });
113
114/**
115 * Wait for every Action in `refs` (in order). Empty input is a no-op.
116 */
117export const waitForActions = (
118 refs: ReadonlyArray<ActionRef>,
119): Effect.Effect<
120 GetActionResponseAction[],
121 ActionFailed | ActionTimeout | Hetzner.actions.GetActionError,
122 Hetzner.actions.HetznerOpContext
123> => Effect.forEach(refs, waitForAction, { concurrency: 1 });
124
125/**
126 * Poll a Hetzner Cloud DNS Zone Action (`GET /zones/actions/{id}`) until it
127 * reaches `success` or `error`. Zone actions live in a separate namespace
128 * from compute `/actions/{id}` — do not use {@link waitForAction} for them.
129 *
130 * Bounded: at most 10 polls, exponential backoff starting at 500ms and
131 * capped at 5s (under 60s total). Already-finished actions return
132 * immediately.
133 */
134export const waitForZoneAction = (
135 ref: ActionRef,
136): Effect.Effect<
137 GetActionResponseAction,
138 ActionFailed | ActionTimeout | Hetzner.zoneActions.GetZonesActionError,
139 Hetzner.zoneActions.HetznerOpContext
140> =>
141 Effect.gen(function* () {
142 if (typeof ref !== "number" && "status" in ref) {
143 yield* failIfError(ref as GetActionResponseAction);
144 if (isSuccessStatus(ref.status)) {
145 return ref as GetActionResponseAction;
146 }
147 }
148
149 const id = actionIdOf(ref);
150 return yield* Hetzner.zoneActions.getZonesAction({ id }).pipe(
151 Effect.flatMap(({ action }) =>
152 Effect.gen(function* () {
153 yield* failIfError(action as GetActionResponseAction);
154 if (!isSuccessStatus(action.status)) {
155 return yield* new ActionPending({
156 actionId: action.id,
157 status: action.status,
158 });
159 }
160 return action as GetActionResponseAction;
161 }),
162 ),
163 Effect.retry({
164 while: retryable,
165 times: 24,
166 schedule: backoff,
167 }),
168 Effect.catchTag(
169 "ActionPending",
170 (e) =>
171 new ActionTimeout({
172 actionId: e.actionId,
173 status: e.status,
174 }),
175 ),
176 );
177 });