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:
2026-10-06 07:01:58 +03:00
parent c096fc70ef
commit 5b30bb802a
77 changed files with 1352 additions and 1259 deletions
+198 -1
View File
@@ -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);
});
});