|
1 | 1 | import assert from "node:assert/strict"; |
2 | 2 | import test from "node:test"; |
3 | 3 |
|
4 | | -import { lifecycleEndpoint } from "./run-account-lifecycle-cron.mjs"; |
| 4 | +import { |
| 5 | + invokeLifecycleEndpoint, |
| 6 | + lifecycleEndpoint, |
| 7 | +} from "./run-account-lifecycle-cron.mjs"; |
5 | 8 |
|
6 | 9 | const CRON_SECRET = "a".repeat(32); |
7 | 10 | const privateOriginWithCredentials = new URL( |
@@ -67,3 +70,63 @@ test("still rejects insecure public fallback origins", () => { |
67 | 70 | /APP_ORIGIN_MUST_USE_HTTPS/ |
68 | 71 | ); |
69 | 72 | }); |
| 73 | + |
| 74 | +test("retries transient private-network failures with bounded backoff", async () => { |
| 75 | + let attempts = 0; |
| 76 | + const delays = []; |
| 77 | + const response = await invokeLifecycleEndpoint( |
| 78 | + lifecycleEndpoint({ |
| 79 | + ACCOUNT_LIFECYCLE_CRON_ORIGIN: |
| 80 | + "http://study-buddy-v2.railway.internal:8080", |
| 81 | + ACCOUNT_DELETION_CRON_SECRET: CRON_SECRET, |
| 82 | + }), |
| 83 | + { |
| 84 | + fetchImpl: async () => { |
| 85 | + attempts += 1; |
| 86 | + if (attempts < 3) { |
| 87 | + throw Object.assign(new TypeError("fetch failed"), { |
| 88 | + cause: { code: "EAI_AGAIN" }, |
| 89 | + }); |
| 90 | + } |
| 91 | + return { ok: true, status: 200 }; |
| 92 | + }, |
| 93 | + sleepImpl: async (delayMs) => delays.push(delayMs), |
| 94 | + retryDelays: [0, 1_000, 3_000], |
| 95 | + onRetry: () => {}, |
| 96 | + } |
| 97 | + ); |
| 98 | + |
| 99 | + assert.equal(response.status, 200); |
| 100 | + assert.equal(attempts, 3); |
| 101 | + assert.deepEqual(delays, [1_000, 3_000]); |
| 102 | +}); |
| 103 | + |
| 104 | +test("reports a safe transport cause code after retries are exhausted", async () => { |
| 105 | + let attempts = 0; |
| 106 | + |
| 107 | + await assert.rejects( |
| 108 | + invokeLifecycleEndpoint( |
| 109 | + lifecycleEndpoint({ |
| 110 | + ACCOUNT_LIFECYCLE_CRON_ORIGIN: |
| 111 | + "http://study-buddy-v2.railway.internal:8080", |
| 112 | + ACCOUNT_DELETION_CRON_SECRET: CRON_SECRET, |
| 113 | + }), |
| 114 | + { |
| 115 | + fetchImpl: async () => { |
| 116 | + attempts += 1; |
| 117 | + throw Object.assign(new TypeError("fetch failed"), { |
| 118 | + cause: { code: "ECONNREFUSED" }, |
| 119 | + }); |
| 120 | + }, |
| 121 | + sleepImpl: async () => {}, |
| 122 | + retryDelays: [0, 0], |
| 123 | + onRetry: () => {}, |
| 124 | + } |
| 125 | + ), |
| 126 | + (error) => |
| 127 | + error?.reason === "TRANSPORT_FAILURE" && |
| 128 | + error?.causeCode === "ECONNREFUSED" |
| 129 | + ); |
| 130 | + |
| 131 | + assert.equal(attempts, 2); |
| 132 | +}); |
0 commit comments