Skip to content

Commit 9510886

Browse files
refactor(status-fetcher): replace fetch-utils with Effect-based fetchers
Replaces the hand-rolled timeout+retry loop in packages/status-fetcher/src/fetch-utils.ts with Effect's Schedule.exponential+jittered and Effect.timeoutFail. Each fetcher's fetch() now returns Effect.Effect<StatusResult, FetchError> instead of Promise<StatusResult>, so the downstream consumer in apps/workflows can compose typed errors directly via Effect.forEach + Effect.either instead of Promise.allSettled. Adds 'effect': '3.19.12' to packages/status-fetcher dependencies (matching apps/workflows). Behavior changes (must be reviewed): - Timeouts now retry up to 3 times like other transient errors. Worst-case per-entry time grows from ~30s to ~120s. The outer Effect.retry({ times: 3 }) in external-status.ts is removed to compensate (inner fetchJson retry is sufficient). - Jitter formula differs slightly: today's +/-25% on planned delay -> Effect's Schedule.jittered (uniform [delay, 2*delay]). Functionally equivalent for herd avoidance. - FetchError constructor changed from positional args to options object: new FetchError({ url, fetcherName?, entryId?, httpStatus?, cause? }). Added httpStatus field so retry predicate skips 4xx without regex-matching the message. - Public surface narrowed: fetchWithTimeout / fetchWithRetry / fetchWithDeduplication and their option types removed from src/index.ts. No external consumers found. - StatusFetcher.fetch signature: Promise<StatusResult> -> Effect.Effect<StatusResult, FetchError>. Internal helpers fetchJson and fetchText are package-internal; only FetchError, fetcher registry, and type surface are re-exported. Also fixes pre-existing tsconfig typo (bun-types -> bun) and removes stale @openstatus/status-fetcher dep from apps/web/package.json (no source imports). Tests: 168 pass / 0 fail. README rewritten.
1 parent 6282b27 commit 9510886

30 files changed

Lines changed: 1453 additions & 1979 deletions

apps/web/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
"@openstatus/react": "workspace:*",
3737
"@openstatus/regions": "workspace:*",
3838
"@openstatus/services": "workspace:*",
39-
"@openstatus/status-fetcher": "workspace:*",
4039
"@openstatus/theme-store": "workspace:*",
4140
"@openstatus/tinybird": "workspace:*",
4241
"@openstatus/tracker": "workspace:*",

apps/workflows/src/cron/external-status.ts

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { getLogger } from "@logtape/logtape";
22
import { listExternalServices } from "@openstatus/services/external-service";
33
import type { ExternalServiceRow } from "@openstatus/services/external-service";
4-
import { fetchers } from "@openstatus/status-fetcher";
4+
import { FetchError, fetchers } from "@openstatus/status-fetcher";
55
import type { StatusPageEntry, StatusResult } from "@openstatus/status-fetcher";
66
import { OSTinybird } from "@openstatus/tinybird";
7-
import { Effect, Schedule } from "effect";
7+
import { Effect, Either } from "effect";
88
import type { Context } from "hono";
99

1010
import { env } from "../env";
@@ -65,32 +65,42 @@ export async function runExternalStatusTick(): Promise<{
6565
const entries = services.map(toStatusPageEntry);
6666
const fetchedAt = Date.now();
6767

68-
const settled = await Promise.allSettled(
69-
entries.map(async (entry) => {
70-
const fetcher = fetchers.find((f) => f.canHandle(entry));
71-
if (!fetcher) {
72-
throw new Error(`no fetcher matches entry slug=${entry.id}`);
73-
}
74-
const result = await fetcher.fetch(entry);
75-
return buildSnapshot({ entry, result, fetchedAt });
76-
}),
68+
const results = await Effect.runPromise(
69+
Effect.forEach(
70+
entries,
71+
(entry) => {
72+
const fetcher = fetchers.find((f) => f.canHandle(entry));
73+
if (!fetcher) {
74+
return Effect.either(
75+
Effect.fail(
76+
new FetchError({
77+
url: entry.status_page_url,
78+
entryId: entry.id,
79+
cause: new Error(`no fetcher matches entry slug=${entry.id}`),
80+
}),
81+
),
82+
);
83+
}
84+
return fetcher.fetch(entry).pipe(
85+
Effect.map((result) => buildSnapshot({ entry, result, fetchedAt })),
86+
Effect.either,
87+
);
88+
},
89+
{ concurrency: "unbounded" },
90+
),
7791
);
7892

7993
const snapshots: Snapshot[] = [];
8094
let failureCount = 0;
81-
for (const [i, r] of settled.entries()) {
82-
if (r.status === "fulfilled") {
83-
snapshots.push(r.value);
95+
for (const [i, r] of results.entries()) {
96+
if (Either.isRight(r)) {
97+
snapshots.push(r.right);
8498
} else {
8599
failureCount++;
86100
const slug = entries[i]?.id ?? "<unknown>";
87101
logger.warn(
88102
"external-status tick: fetcher failed for slug={slug}: {reason}",
89-
{
90-
slug,
91-
reason:
92-
r.reason instanceof Error ? r.reason.message : String(r.reason),
93-
},
103+
{ slug, reason: r.left.message },
94104
);
95105
}
96106
}
@@ -121,10 +131,6 @@ export async function handleExternalStatusCron(c: Context) {
121131
`external-status tick failed: ${e instanceof Error ? e.message : String(e)}`,
122132
),
123133
}).pipe(
124-
Effect.retry({
125-
times: 3,
126-
schedule: Schedule.exponential("1000 millis"),
127-
}),
128134
Effect.tap((res) =>
129135
Effect.sync(() => {
130136
logger.info(

0 commit comments

Comments
 (0)