Skip to content

Commit 2eda69b

Browse files
itsmiso-aiSaffronjoryirving
authored
Add startup health check to prevent silent scheduler failure on non-default PORT (#665)
* Add startup health check to prevent silent scheduler failure on non-default PORT The scheduler now calls a new `schedulerHealthCheck` function at startup which fetches `/api/health` using the configured bearer token. This verifies the scheduler can reach its own endpoints before periodic jobs begin firing. The health check returns true on HTTP 200 and false otherwise, logging descriptive errors via the injected deps.log. It does not prevent the scheduler from running — it provides visibility into misconfiguration without blocking periodic work. Fixes #653 Signed-off-by: Saffron <263493777+itsmiso-ai@users.noreply.github.com> * Move schedulerHealthCheck outside job loop to fire once at startup Address review feedback on #665 — health check was inside the per-job loop, causing N checks at startup instead of one. --------- Signed-off-by: Saffron <263493777+itsmiso-ai@users.noreply.github.com> Co-authored-by: Saffron <263493777+itsmiso-ai@users.noreply.github.com> Co-authored-by: Saffron <saffron@jory.dev> Co-authored-by: Jory Irving <46251616+joryirving@users.noreply.github.com>
1 parent 5943c61 commit 2eda69b

2 files changed

Lines changed: 73 additions & 7 deletions

File tree

src/lib/scheduler.test.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi } from "vitest";
2-
import { schedulerConfigFromEnv, runJob, startScheduler, type SchedulerConfig, type SchedulerDeps } from "./scheduler";
2+
import { schedulerConfigFromEnv, schedulerHealthCheck, runJob, startScheduler, type SchedulerConfig, type SchedulerDeps } from "./scheduler";
33

44
function fakeDeps(overrides: Partial<SchedulerDeps> = {}): SchedulerDeps & { logs: Array<[string, unknown]> } {
55
const logs: Array<[string, unknown]> = [];
@@ -112,15 +112,50 @@ describe("startScheduler", () => {
112112
expect(deps.logs.some(([m]) => m.includes("DISPATCH_AGENT_TOKEN is unset"))).toBe(true);
113113
});
114114

115-
it("schedules each job after the startup delay, then on an interval", () => {
115+
it("schedules a single health check and each job after the startup delay, then on an interval", () => {
116116
const deps = fakeDeps();
117117
startScheduler(CONFIG, deps);
118-
expect(deps.setTimeout).toHaveBeenCalledTimes(1);
118+
// one setTimeout for the health check, one for the job
119+
expect(deps.setTimeout).toHaveBeenCalledTimes(2);
119120
expect(deps.setTimeout).toHaveBeenCalledWith(expect.any(Function), 5000);
120-
// fire the startup timer -> it runs once and arms the interval
121-
const startupCb = (deps.setTimeout as ReturnType<typeof vi.fn>).mock.calls[0][0] as () => void;
122-
startupCb();
123-
expect(deps.fetch).toHaveBeenCalledTimes(1);
121+
// fire both startup timers
122+
const calls = (deps.setTimeout as ReturnType<typeof vi.fn>).mock.calls;
123+
for (const c of calls) (c[0] as () => void)();
124+
expect(deps.fetch).toHaveBeenCalledTimes(2); // health check + job
124125
expect(deps.setInterval).toHaveBeenCalledWith(expect.any(Function), 900000);
125126
});
126127
});
128+
129+
describe("schedulerHealthCheck", () => {
130+
it("returns true when health endpoint responds with 200", async () => {
131+
const mockFetch = vi.fn(async () => new Response(null, { status: 200 })) as unknown as typeof globalThis.fetch;
132+
const deps = fakeDeps({ fetch: mockFetch });
133+
const result = await schedulerHealthCheck(CONFIG, deps);
134+
135+
expect(result).toBe(true);
136+
expect(mockFetch).toHaveBeenCalledTimes(1);
137+
expect(mockFetch).toHaveBeenCalledWith("http://127.0.0.1:3000/api/health", {
138+
headers: { Authorization: "Bearer tok" },
139+
});
140+
});
141+
142+
it("returns false and logs when health endpoint returns non-ok status", async () => {
143+
const mockFetch = vi.fn(async () => new Response(null, { status: 503 })) as unknown as typeof globalThis.fetch;
144+
const log = vi.fn();
145+
const deps = fakeDeps({ fetch: mockFetch, log });
146+
const result = await schedulerHealthCheck(CONFIG, deps);
147+
148+
expect(result).toBe(false);
149+
expect(log).toHaveBeenCalledWith("scheduler health check failed — HTTP 503");
150+
});
151+
152+
it("returns false and logs when fetch throws", async () => {
153+
const mockFetch = vi.fn(async () => { throw new Error("ECONNREFUSED"); }) as unknown as typeof globalThis.fetch;
154+
const log = vi.fn();
155+
const deps = fakeDeps({ fetch: mockFetch, log });
156+
const result = await schedulerHealthCheck(CONFIG, deps);
157+
158+
expect(result).toBe(false);
159+
expect(log).toHaveBeenCalledWith("scheduler health check failed: ECONNREFUSED", expect.any(Error));
160+
});
161+
});

src/lib/scheduler.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,31 @@ export function schedulerConfigFromEnv(env: Record<string, string | undefined>):
109109
};
110110
}
111111

112+
/**
113+
* Startup health check: verify the scheduler can reach its own endpoints.
114+
* Returns true on HTTP 200, false otherwise. Does not prevent the scheduler
115+
* from running — provides visibility into misconfiguration without blocking
116+
* periodic work.
117+
*/
118+
export async function schedulerHealthCheck(config: SchedulerConfig, deps: SchedulerDeps): Promise<boolean> {
119+
try {
120+
const res = await deps.fetch(`${config.baseUrl}/api/health`, {
121+
headers: {
122+
Authorization: `Bearer ${config.token}`,
123+
},
124+
});
125+
if (!res.ok) {
126+
deps.log(`scheduler health check failed — HTTP ${res.status}`);
127+
return false;
128+
}
129+
return true;
130+
} catch (error) {
131+
const msg = error instanceof Error ? error.message : String(error);
132+
deps.log(`scheduler health check failed: ${msg}`, error);
133+
return false;
134+
}
135+
}
136+
112137
/** Fire one job. Never throws — a transient failure must not kill the interval. */
113138
export async function runJob(job: ScheduledJob, config: SchedulerConfig, deps: SchedulerDeps): Promise<void> {
114139
try {
@@ -145,6 +170,12 @@ export function startScheduler(config: SchedulerConfig, deps: SchedulerDeps): un
145170
}
146171

147172
const handles: unknown[] = [];
173+
174+
// Single health check after startup delay, not per-job
175+
deps.setTimeout(() => {
176+
void schedulerHealthCheck(config, deps);
177+
}, config.startupDelayMs);
178+
148179
for (const job of config.jobs) {
149180
deps.log(`scheduling "${job.name}" every ${job.intervalMs}ms -> ${job.path}`);
150181
deps.setTimeout(() => {

0 commit comments

Comments
 (0)