Skip to content

Commit 7af0048

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(runtime): add GET /ready readiness probe for graceful rolling restarts (#2217)
The dispatcher exposed only /health (always 200). Multi-replica deployments need a readiness signal so a load balancer drains a replica before its in-flight requests are force-closed on shutdown. /ready returns 200 only when the kernel state is 'running'; 503 while booting (idle/initializing) or shutting down (stopping/stopped). Added to the membership-skip paths like /health so it is reachable without auth/environment scoping. Pairs with the existing in-flight drain in HonoHttpServer.close() (P1-3). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f20896a commit 7af0048

2 files changed

Lines changed: 42 additions & 1 deletion

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { HttpDispatcher } from './http-dispatcher.js';
3+
4+
function kernel(state: string): any {
5+
return {
6+
getState: () => state,
7+
getService: () => undefined,
8+
getServiceAsync: async () => undefined,
9+
};
10+
}
11+
const ctx: any = {};
12+
13+
describe('HttpDispatcher — GET /ready readiness probe', () => {
14+
it('returns 200 when the kernel is running', async () => {
15+
const res = await new HttpDispatcher(kernel('running')).dispatch('GET', '/ready', undefined, undefined, ctx);
16+
expect(res.handled).toBe(true);
17+
expect(res.response.status).toBe(200);
18+
expect(res.response.body.data.state).toBe('running');
19+
});
20+
21+
it('returns 503 while booting or shutting down', async () => {
22+
for (const state of ['idle', 'initializing', 'stopping', 'stopped']) {
23+
const res = await new HttpDispatcher(kernel(state)).dispatch('GET', '/ready', undefined, undefined, ctx);
24+
expect(res.response.status).toBe(503);
25+
}
26+
});
27+
});

packages/runtime/src/http-dispatcher.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,7 @@ export class HttpDispatcher {
699699
if (!this.enforceMembership) return null;
700700

701701
// Control-plane paths — never gated by project membership.
702-
const skipPaths = ['/auth', '/cloud', '/health', '/discovery'];
702+
const skipPaths = ['/auth', '/cloud', '/health', '/ready', '/discovery'];
703703
if (skipPaths.some(p => path.startsWith(p))) return null;
704704

705705
// Public share-link resolve/messages — the token IS the authorisation,
@@ -3076,6 +3076,20 @@ export class HttpDispatcher {
30763076
};
30773077
}
30783078

3079+
// 0b2. Readiness Endpoint (GET /ready) — k8s / load-balancer readiness probe.
3080+
// 200 only when the kernel is fully running; 503 while booting
3081+
// (idle/initializing) or shutting down (stopping/stopped) so a load
3082+
// balancer stops routing to this replica BEFORE in-flight requests are
3083+
// drained and the server closes (graceful rolling restart).
3084+
if (cleanPath === '/ready' && method === 'GET') {
3085+
const state: string = typeof (this.kernel as any)?.getState === 'function'
3086+
? (this.kernel as any).getState()
3087+
: 'running';
3088+
return state === 'running'
3089+
? { handled: true, response: this.success({ status: 'ready', state }) }
3090+
: { handled: true, response: this.error('Service not ready', 503, { state }) };
3091+
}
3092+
30793093
// 0c. Plan-A diagnostics removed; the seed-replay and oauth2/callback
30803094
// probes were temporary debugging tools used during the SSO rollout.
30813095

0 commit comments

Comments
 (0)