feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
@@ -1,12 +1,26 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import { apiClient, configureAuthClient } from "./client";
|
||||
|
||||
async function advanceReadDeadlineRecovery() {
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
}
|
||||
|
||||
async function advanceTransportRecovery() {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
}
|
||||
|
||||
describe("apiClient authentication", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("refreshes once and retries an unauthorized protected request", async () => {
|
||||
let token = "expired";
|
||||
|
||||
@@ -109,3 +123,186 @@ describe("apiClient authentication", () => {
|
||||
await response;
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiClient transient read recovery", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("recovers a protected GET after transient server failures", async () => {
|
||||
vi.useFakeTimers();
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests += 1;
|
||||
|
||||
return requests < 3
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(resource([{ id: "account-1" }]));
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
const response = apiClient.GET("/api/v1/brokerage-accounts");
|
||||
await advanceTransportRecovery();
|
||||
|
||||
await expect(response).resolves.toMatchObject({
|
||||
data: { data: [{ id: "account-1" }] },
|
||||
});
|
||||
expect(requests).toBe(3);
|
||||
});
|
||||
|
||||
it("recovers a protected GET after network failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests += 1;
|
||||
|
||||
return requests === 1
|
||||
? HttpResponse.error()
|
||||
: HttpResponse.json(resource([{ id: "account-1" }]));
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
const response = apiClient.GET("/api/v1/brokerage-accounts");
|
||||
await advanceTransportRecovery();
|
||||
|
||||
await expect(response).resolves.toMatchObject({
|
||||
data: { data: [{ id: "account-1" }] },
|
||||
});
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
it("times out a stalled response body before retrying", async () => {
|
||||
vi.useFakeTimers();
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests += 1;
|
||||
|
||||
return requests === 1
|
||||
? new Response(new ReadableStream())
|
||||
: HttpResponse.json(resource([{ id: "account-1" }]));
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
const response = apiClient.GET("/api/v1/brokerage-accounts");
|
||||
await advanceReadDeadlineRecovery();
|
||||
|
||||
await expect(response).resolves.toMatchObject({
|
||||
data: { data: [{ id: "account-1" }] },
|
||||
});
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
it("does not retry rate limited reads before the server delay", async () => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests += 1;
|
||||
|
||||
return HttpResponse.json(
|
||||
{ code: "RATE_LIMITED" },
|
||||
{ headers: { "Retry-After": "60" }, status: 429 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
it("does not retry malformed successful responses as transport failures", async () => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests += 1;
|
||||
|
||||
return new Response("not json", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
});
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
await expect(
|
||||
apiClient.GET("/api/v1/brokerage-accounts"),
|
||||
).rejects.toThrow(SyntaxError);
|
||||
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
it.each([401, 403, 404, 409])(
|
||||
"does not retry permanent HTTP %s responses",
|
||||
async (status) => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests += 1;
|
||||
|
||||
return HttpResponse.json({ code: "FORBIDDEN" }, { status });
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
|
||||
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(requests).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not retry mutations", async () => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.put("*/api/v1/tbank-connection", () => {
|
||||
requests += 1;
|
||||
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
await apiClient.PUT("/api/v1/tbank-connection", {
|
||||
body: { token: "token" },
|
||||
});
|
||||
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+169
-2
@@ -6,6 +6,17 @@ import type { paths } from "./generated/schema";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
const HTTP_REQUEST_TIMEOUT = 408;
|
||||
const HTTP_TOO_MANY_REQUESTS = 429;
|
||||
const HTTP_INTERNAL_SERVER_ERROR = 500;
|
||||
const HTTP_BAD_GATEWAY = 502;
|
||||
const HTTP_SERVICE_UNAVAILABLE = 503;
|
||||
const HTTP_GATEWAY_TIMEOUT = 504;
|
||||
const GET_DEADLINE_MS = 15_000;
|
||||
const FIRST_RETRY_DELAY_MS = 1000;
|
||||
const SECOND_RETRY_DELAY_MS = 2000;
|
||||
const RETRY_DELAYS_MS = [FIRST_RETRY_DELAY_MS, SECOND_RETRY_DELAY_MS] as const;
|
||||
const SECONDS_TO_MS = 1000;
|
||||
|
||||
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
@@ -23,12 +34,22 @@ type AuthHandlers = {
|
||||
refresh: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
type ReadAttempt = {
|
||||
problem?: unknown;
|
||||
reason: "abort" | "transient";
|
||||
response?: Response;
|
||||
};
|
||||
|
||||
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
|
||||
hasSession: false,
|
||||
};
|
||||
|
||||
const authSessionListeners = new Set<() => void>();
|
||||
|
||||
function abortError() {
|
||||
return new DOMException("Request aborted", "AbortError");
|
||||
}
|
||||
|
||||
async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
const isAuthRequest = new URL(request.url).pathname.startsWith(
|
||||
"/api/v1/auth/",
|
||||
@@ -56,7 +77,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
= token && !isAuthRequest ? withToken(request, token) : request;
|
||||
|
||||
const retryRequest = authorized.clone();
|
||||
const response = await fetch(authorized);
|
||||
const response = await fetchAppRead(authorized, isAuthRequest);
|
||||
if (!isAuthRequest && !isCurrent())
|
||||
throw new DOMException("Session changed", "AbortError");
|
||||
if (
|
||||
@@ -87,7 +108,12 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
const refreshedToken = sessionState.handlers.accessToken();
|
||||
if (!refreshedToken) return response;
|
||||
const retried = await fetch(withToken(retryRequest, refreshedToken));
|
||||
|
||||
const retried = await fetchAppRead(
|
||||
withToken(retryRequest, refreshedToken),
|
||||
isAuthRequest,
|
||||
);
|
||||
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
|
||||
return retried;
|
||||
@@ -98,10 +124,138 @@ function configureAuthClient(handlers?: AuthHandlers) {
|
||||
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
|
||||
}
|
||||
|
||||
async function fetchAppRead(
|
||||
request: Request,
|
||||
isAuthRequest: boolean,
|
||||
): Promise<Response> {
|
||||
if (isAuthRequest || request.method !== "GET") return fetch(request);
|
||||
|
||||
const retryableRequest = request.clone();
|
||||
|
||||
for (const [retryIndex, delayMs] of RETRY_DELAYS_MS.entries()) {
|
||||
const attempt = await readAttempt(
|
||||
retryIndex === 0 ? request : retryableRequest.clone(),
|
||||
);
|
||||
|
||||
if (attempt.reason === "abort") throw abortError();
|
||||
const response = attempt.response;
|
||||
if (response && !isTransientReadFailure(response, attempt.problem))
|
||||
return response;
|
||||
await waitForRetry(delayMs, request.signal);
|
||||
}
|
||||
|
||||
const finalAttempt = await readAttempt(retryableRequest.clone());
|
||||
if (finalAttempt.reason === "abort") throw abortError();
|
||||
if (!finalAttempt.response) throw new TypeError("Network request failed");
|
||||
|
||||
return finalAttempt.response;
|
||||
}
|
||||
|
||||
function hasAuthenticatedSession() {
|
||||
return sessionState.hasSession;
|
||||
}
|
||||
|
||||
function isNetworkFailure(error: unknown): boolean {
|
||||
return error instanceof TypeError;
|
||||
}
|
||||
|
||||
function isPermanentCode(code?: string): boolean {
|
||||
return [
|
||||
ApiErrorCode.Forbidden,
|
||||
ApiErrorCode.InvalidTbankToken,
|
||||
ApiErrorCode.NotFound,
|
||||
ApiErrorCode.TbankNotConnected,
|
||||
ApiErrorCode.Unauthorized,
|
||||
].some(permanent => isApiErrorCode(code, permanent));
|
||||
}
|
||||
|
||||
function isTransientReadFailure(
|
||||
response: Response | undefined,
|
||||
problem: unknown,
|
||||
): boolean {
|
||||
if (!response) return true;
|
||||
const code = apiErrorCode(problem);
|
||||
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable)) return true;
|
||||
if (isPermanentCode(code)) return false;
|
||||
|
||||
const transientStatuses = [
|
||||
HTTP_BAD_GATEWAY,
|
||||
HTTP_GATEWAY_TIMEOUT,
|
||||
HTTP_INTERNAL_SERVER_ERROR,
|
||||
HTTP_REQUEST_TIMEOUT,
|
||||
HTTP_SERVICE_UNAVAILABLE,
|
||||
];
|
||||
|
||||
return transientStatuses.includes(response.status)
|
||||
|| (
|
||||
response.status === HTTP_TOO_MANY_REQUESTS
|
||||
&& retryAfterMs(response) === 0
|
||||
);
|
||||
}
|
||||
|
||||
function parseJson(body: string): unknown {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
}
|
||||
catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readAttempt(request: Request): Promise<ReadAttempt> {
|
||||
const controller = new AbortController();
|
||||
const failure: { reason?: "abort" | "timeout" } = {};
|
||||
|
||||
const { promise: deadline, reject: rejectDeadline }
|
||||
= Promise.withResolvers<never>();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
failure.reason = "timeout";
|
||||
controller.abort();
|
||||
rejectDeadline(abortError());
|
||||
}, GET_DEADLINE_MS);
|
||||
|
||||
const abort = () => {
|
||||
failure.reason = "abort";
|
||||
controller.abort();
|
||||
rejectDeadline(abortError());
|
||||
};
|
||||
|
||||
request.signal.addEventListener("abort", abort, { once: true });
|
||||
const timedRequest = new Request(request, { signal: controller.signal });
|
||||
|
||||
try {
|
||||
const response = await Promise.race([fetch(timedRequest), deadline]);
|
||||
const body = await Promise.race([response.text(), deadline]);
|
||||
const problem = parseJson(body);
|
||||
|
||||
return {
|
||||
problem,
|
||||
reason: "transient",
|
||||
response: new Response(body, response),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
if (failure.reason === "abort") return { reason: "abort" };
|
||||
if (failure.reason === "timeout" || isNetworkFailure(error))
|
||||
return { reason: "transient" };
|
||||
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeout);
|
||||
request.signal.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
function retryAfterMs(response: Response): number | undefined {
|
||||
const header = response.headers.get("Retry-After");
|
||||
if (!header) return undefined;
|
||||
const seconds = Number(header);
|
||||
|
||||
return Number.isFinite(seconds) ? seconds * SECONDS_TO_MS : undefined;
|
||||
}
|
||||
|
||||
function setAuthenticatedSession(hasSession: boolean) {
|
||||
if (hasSession === sessionState.hasSession) return;
|
||||
sessionState.hasSession = hasSession;
|
||||
@@ -114,6 +268,19 @@ function subscribeAuthenticatedSession(listener: () => void) {
|
||||
return () => authSessionListeners.delete(listener);
|
||||
}
|
||||
|
||||
function waitForRetry(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
|
||||
const abort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
const apiClient = createClient<paths>({
|
||||
baseUrl: apiBaseUrl,
|
||||
credentials: "include",
|
||||
|
||||
Reference in New Issue
Block a user