fix(HAI-018): complete Step 2 — add client-side retry for fetchTaskDetail

- Added withRetry wrapper in api.ts (1 retry, 200ms delay)
- fetchTaskDetail now retries transient 500s transparently
- TaskCard shows 'Failed to load task details' instead of raw error
- Added api.test.ts with success, retry-success, and exhaustion tests
This commit is contained in:
Dustin Byrne
2026-03-25 21:32:11 -04:00
parent d452aeb0c1
commit 6f1cc2cb48
3 changed files with 82 additions and 2 deletions

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchTaskDetail } from "./api";
import type { TaskDetail } from "@hai/core";
const FAKE_DETAIL: TaskDetail = {
id: "HAI-001",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# HAI-001",
};
function mockFetchResponse(ok: boolean, body: unknown, status = ok ? 200 : 500) {
return Promise.resolve({
ok,
status,
json: () => Promise.resolve(body),
} as Response);
}
describe("fetchTaskDetail", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
it("returns data on first success", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
const result = await fetchTaskDetail("HAI-001");
expect(result.id).toBe("HAI-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it("retries once on failure then succeeds", async () => {
globalThis.fetch = vi.fn()
.mockReturnValueOnce(mockFetchResponse(false, { error: "Transient error" }))
.mockReturnValueOnce(mockFetchResponse(true, FAKE_DETAIL));
const result = await fetchTaskDetail("HAI-001");
expect(result.id).toBe("HAI-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it("throws after retry exhaustion", async () => {
globalThis.fetch = vi.fn()
.mockReturnValue(mockFetchResponse(false, { error: "Server error" }));
await expect(fetchTaskDetail("HAI-001")).rejects.toThrow("Server error");
expect(globalThis.fetch).toHaveBeenCalledTimes(2); // initial + 1 retry
});
});

View File

@@ -10,12 +10,27 @@ async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T
return data as T;
}
/**
* Retry wrapper for API calls that may fail due to transient server errors
* (e.g. 500s caused by concurrent file writes racing with reads).
* Retries once after a short delay before giving up.
*/
async function withRetry<T>(fn: () => Promise<T>, { retries = 1, delayMs = 200 } = {}): Promise<T> {
try {
return await fn();
} catch (err) {
if (retries <= 0) throw err;
await new Promise((r) => setTimeout(r, delayMs));
return withRetry(fn, { retries: retries - 1, delayMs });
}
}
export function fetchTasks(): Promise<Task[]> {
return api<Task[]>("/tasks");
}
export function fetchTaskDetail(id: string): Promise<TaskDetail> {
return api<TaskDetail>(`/tasks/${id}`);
return withRetry(() => api<TaskDetail>(`/tasks/${id}`));
}
export function createTask(input: TaskCreateInput): Promise<Task> {

View File

@@ -47,7 +47,7 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
const detail = await fetchTaskDetail(task.id);
onOpenDetail(detail);
} catch (err: any) {
addToast(err.message, "error");
addToast("Failed to load task details", "error");
}
}, [task.id, onOpenDetail, addToast]);