fix(#411): remediate frontend review findings — wire fetchWithRetry, fix error handling

- Wire fetchWithRetry into login page config fetch (was dead code)
- Remove duplicate ERROR_CODE_MESSAGES, use parseAuthError from auth-errors.ts
- Fix OAuth sign-in fire-and-forget: add .catch() with PDA error + loading reset
- Fix credential login catch: use parseAuthError for better error messages
- Add user feedback when auth config fetch fails (was silent degradation)
- Fix sign-out failure: use logAuthError and set authError state
- Enable fetchWithRetry production logging for retry visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 12:33:25 -06:00
parent 7ead8b1076
commit 9696e45265
5 changed files with 89 additions and 49 deletions

View File

@@ -129,7 +129,8 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
try {
await apiPost("/auth/sign-out");
} catch (error) {
console.error("Sign out error:", error);
logAuthError("Sign out request did not complete", error);
setAuthError("network");
} finally {
setUser(null);
expiresAtRef.current = null;

View File

@@ -40,7 +40,6 @@ function mockResponse(status: number, ok?: boolean): Response {
describe("fetchWithRetry", (): void => {
const originalFetch = global.fetch;
const originalEnv = process.env.NODE_ENV;
const sleepMock = vi.mocked(sleep);
beforeEach((): void => {
@@ -52,7 +51,6 @@ describe("fetchWithRetry", (): void => {
afterEach((): void => {
vi.restoreAllMocks();
global.fetch = originalFetch;
process.env.NODE_ENV = originalEnv;
});
it("should succeed on first attempt without retrying", async (): Promise<void> => {
@@ -203,8 +201,7 @@ describe("fetchWithRetry", (): void => {
expect(recordedDelays).toEqual([1000, 2000, 4000]);
});
it("should log retry attempts in development mode", async (): Promise<void> => {
process.env.NODE_ENV = "development";
it("should log retry attempts in all environments", async (): Promise<void> => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {});
const okResponse = mockResponse(200);
@@ -222,18 +219,22 @@ describe("fetchWithRetry", (): void => {
warnSpy.mockRestore();
});
it("should NOT log retry attempts in production mode", async (): Promise<void> => {
process.env.NODE_ENV = "production";
it("should log retry attempts for HTTP errors", async (): Promise<void> => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {});
const serverError = mockResponse(500);
const okResponse = mockResponse(200);
vi.mocked(global.fetch)
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValueOnce(serverError)
.mockResolvedValueOnce(okResponse);
await fetchWithRetry("https://api.example.com/auth/config");
expect(warnSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[Auth] Retry 1/3 after HTTP 500"),
);
warnSpy.mockRestore();
});

View File

@@ -80,9 +80,7 @@ export async function fetchWithRetry(
lastResponse = response;
const delay = computeDelay(attempt, baseDelayMs, backoffFactor);
if (process.env.NODE_ENV === "development") {
console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after HTTP ${response.status}, waiting ${delay}ms...`);
}
console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after HTTP ${response.status}, waiting ${delay}ms...`);
await sleep(delay);
} catch (error: unknown) {
@@ -96,9 +94,7 @@ export async function fetchWithRetry(
lastError = error;
const delay = computeDelay(attempt, baseDelayMs, backoffFactor);
if (process.env.NODE_ENV === "development") {
console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after ${parsed.code}, waiting ${delay}ms...`);
}
console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after ${parsed.code}, waiting ${delay}ms...`);
await sleep(delay);
}