Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 8594998

Browse files
z23ccclaude
andcommitted
feat: Wave 3 — frontend API error parsing + WS exponential backoff
- api.ts: ApiRequestError parses server JSON error body, apiPost/apiDelete helpers - ws.ts: exponential backoff (1s→30s max), ConnectionState tracking, typed event dispatch - types.ts: TypeScript interfaces for 5 WS event types + API response types - Bundle: 481KB (within budget) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent edd3648 commit 8594998

3 files changed

Lines changed: 238 additions & 21 deletions

File tree

frontend/src/lib/api.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
const API_BASE = "/api/v1";
22

3+
export class ApiRequestError extends Error {
4+
status: number;
5+
serverMessage: string;
6+
7+
constructor(status: number, serverMessage: string) {
8+
super(serverMessage);
9+
this.name = "ApiRequestError";
10+
this.status = status;
11+
this.serverMessage = serverMessage;
12+
}
13+
}
14+
315
export async function apiFetch<T>(
416
path: string,
517
init?: RequestInit,
@@ -9,9 +21,47 @@ export async function apiFetch<T>(
921
...init,
1022
});
1123
if (!res.ok) {
12-
throw new Error(`API ${res.status}: ${res.statusText}`);
24+
let serverMessage = res.statusText;
25+
try {
26+
const body = await res.json();
27+
if (body?.error) {
28+
serverMessage = body.error;
29+
}
30+
} catch {
31+
// response body wasn't JSON, keep statusText
32+
}
33+
throw new ApiRequestError(res.status, serverMessage);
1334
}
1435
return res.json();
1536
}
1637

38+
export async function apiPost<T>(
39+
path: string,
40+
body?: unknown,
41+
): Promise<T> {
42+
return apiFetch<T>(path, {
43+
method: "POST",
44+
body: body !== undefined ? JSON.stringify(body) : undefined,
45+
});
46+
}
47+
48+
export async function apiDelete(path: string): Promise<void> {
49+
const res = await fetch(`${API_BASE}${path}`, {
50+
method: "DELETE",
51+
headers: { "Content-Type": "application/json" },
52+
});
53+
if (!res.ok) {
54+
let serverMessage = res.statusText;
55+
try {
56+
const body = await res.json();
57+
if (body?.error) {
58+
serverMessage = body.error;
59+
}
60+
} catch {
61+
// response body wasn't JSON, keep statusText
62+
}
63+
throw new ApiRequestError(res.status, serverMessage);
64+
}
65+
}
66+
1767
export const swrFetcher = <T>(path: string): Promise<T> => apiFetch<T>(path);

frontend/src/lib/types.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// WebSocket event types matching Rust FlowEvent #[serde(tag="type", content="data")]
2+
3+
export interface TaskStatusChanged {
4+
task_id: string;
5+
epic_id: string;
6+
from_status: string;
7+
to_status: string;
8+
}
9+
10+
export interface DagMutated {
11+
mutation: string;
12+
details: unknown;
13+
}
14+
15+
export interface AgentLog {
16+
agent_id: string;
17+
task_id: string;
18+
level: string;
19+
message: string;
20+
}
21+
22+
export interface EpicUpdated {
23+
epic_id: string;
24+
field: string;
25+
value: unknown;
26+
}
27+
28+
// Heartbeat has no data (content is null in serde)
29+
export type Heartbeat = null;
30+
31+
export type FlowEventType =
32+
| "TaskStatusChanged"
33+
| "DagMutated"
34+
| "AgentLog"
35+
| "EpicUpdated"
36+
| "Heartbeat";
37+
38+
export type FlowEventData =
39+
| TaskStatusChanged
40+
| DagMutated
41+
| AgentLog
42+
| EpicUpdated
43+
| Heartbeat;
44+
45+
/** Envelope sent over WebSocket: TimestampedEvent from Rust */
46+
export interface TimestampedEvent {
47+
timestamp: string;
48+
event: {
49+
type: FlowEventType;
50+
data?: FlowEventData;
51+
};
52+
}
53+
54+
/** Generic API response wrapper */
55+
export interface ApiResponse<T> {
56+
data: T;
57+
}
58+
59+
/** API error shape returned by the daemon */
60+
export interface ApiError {
61+
error: string;
62+
status: number;
63+
}

frontend/src/lib/ws.ts

Lines changed: 124 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,130 @@
1-
type EventHandler = (data: unknown) => void;
2-
3-
export function connectEvents(onMessage: EventHandler): WebSocket {
4-
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
5-
const ws = new WebSocket(`${proto}//${window.location.host}/api/v1/events`);
6-
7-
ws.onmessage = (event) => {
8-
try {
9-
const data = JSON.parse(event.data);
10-
onMessage(data);
11-
} catch {
12-
console.warn("Failed to parse WS message:", event.data);
1+
import type { FlowEventType, FlowEventData, TimestampedEvent } from "./types";
2+
3+
export type ConnectionState = "connected" | "disconnected" | "reconnecting";
4+
5+
type ConnectionChangeHandler = (state: ConnectionState) => void;
6+
type EventTypeHandler<T = FlowEventData> = (data: T, timestamp: string) => void;
7+
8+
const MAX_BACKOFF_MS = 30_000;
9+
const PERSISTENT_WARNING_THRESHOLD = 5;
10+
11+
export interface EventConnection {
12+
/** Current connection state */
13+
state: ConnectionState;
14+
/** Register a handler for a specific event type */
15+
on<T extends FlowEventData>(type: FlowEventType, handler: EventTypeHandler<T>): void;
16+
/** Register a handler for connection state changes */
17+
onConnectionChange(handler: ConnectionChangeHandler): void;
18+
/** Close the connection (no reconnect) */
19+
close(): void;
20+
}
21+
22+
export function connectEvents(
23+
onMessage?: (data: unknown) => void,
24+
): EventConnection {
25+
let ws: WebSocket | null = null;
26+
let closed = false;
27+
let failures = 0;
28+
let currentState: ConnectionState = "disconnected";
29+
30+
const connectionChangeHandlers: ConnectionChangeHandler[] = [];
31+
const eventHandlers = new Map<FlowEventType, EventTypeHandler[]>();
32+
33+
function setState(s: ConnectionState) {
34+
currentState = s;
35+
connection.state = s;
36+
for (const h of connectionChangeHandlers) {
37+
h(s);
1338
}
14-
};
39+
}
1540

16-
ws.onerror = (err) => {
17-
console.error("WebSocket error:", err);
18-
};
41+
function backoffMs(): number {
42+
// 1s, 2s, 4s, 8s, 16s, capped at 30s
43+
const ms = Math.min(1000 * Math.pow(2, failures), MAX_BACKOFF_MS);
44+
return ms;
45+
}
46+
47+
function connect() {
48+
if (closed) return;
49+
50+
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
51+
ws = new WebSocket(`${proto}//${window.location.host}/api/v1/events`);
52+
53+
ws.onopen = () => {
54+
failures = 0;
55+
setState("connected");
56+
};
57+
58+
ws.onmessage = (event) => {
59+
try {
60+
const raw = JSON.parse(event.data);
61+
62+
// Legacy callback
63+
if (onMessage) {
64+
onMessage(raw);
65+
}
66+
67+
// Typed dispatch: TimestampedEvent = {timestamp, event: {type, data?}}
68+
const stamped = raw as TimestampedEvent;
69+
if (stamped?.event?.type) {
70+
const handlers = eventHandlers.get(stamped.event.type);
71+
if (handlers) {
72+
for (const h of handlers) {
73+
h(stamped.event.data ?? null, stamped.timestamp);
74+
}
75+
}
76+
}
77+
} catch {
78+
console.warn("Failed to parse WS message:", event.data);
79+
}
80+
};
81+
82+
ws.onerror = () => {
83+
// onerror is always followed by onclose, handle reconnect there
84+
};
85+
86+
ws.onclose = () => {
87+
if (closed) {
88+
setState("disconnected");
89+
return;
90+
}
91+
92+
failures++;
93+
setState("reconnecting");
94+
95+
if (failures >= PERSISTENT_WARNING_THRESHOLD) {
96+
console.warn(
97+
`WebSocket reconnect attempt ${failures} — daemon may be restarting`,
98+
);
99+
}
100+
101+
const delay = backoffMs();
102+
setTimeout(connect, delay);
103+
};
104+
}
105+
106+
const connection: EventConnection = {
107+
state: currentState,
108+
109+
on<T extends FlowEventData>(type: FlowEventType, handler: EventTypeHandler<T>) {
110+
let handlers = eventHandlers.get(type);
111+
if (!handlers) {
112+
handlers = [];
113+
eventHandlers.set(type, handlers);
114+
}
115+
handlers.push(handler as EventTypeHandler);
116+
},
117+
118+
onConnectionChange(handler: ConnectionChangeHandler) {
119+
connectionChangeHandlers.push(handler);
120+
},
19121

20-
ws.onclose = () => {
21-
// Reconnect after 3 seconds
22-
setTimeout(() => connectEvents(onMessage), 3000);
122+
close() {
123+
closed = true;
124+
ws?.close();
125+
},
23126
};
24127

25-
return ws;
128+
connect();
129+
return connection;
26130
}

0 commit comments

Comments
 (0)