mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(ingest): treat upsert_failed as a retry in the nutrient and cycle batches
The nutrient batch docs told a client to log and drop an `upsert_failed` entry. The route means the opposite: a write group failed on the server's side and nothing was stored. The docs now say to send the entry again on a later sync, and keep the three validation skips terminal. A retry under the same Idempotency-Key used to replay the cached failure, because the 200 carrying `upsert_failed` was cached like any other. Both the nutrient and the cycle bulk routes now mark that response `no-store`, as the measurement batch already does for `failed`, so the retry writes again. The cycle bulk route already answered its validation refusal with `cycle.bulk.invalid` and an over-cap batch with `cycle.bulk.too_large`. Both are now pinned by tests and named in the route's 422 response.
This commit is contained in:
1 parent
a7682a9fed
commit
0cc73d3c88
8 files changed
+181
-10
No files matched your search
@@ -69,6 +69,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
|
||||
import { nutrientEntryResultSchema } from "@/lib/validations/nutrients";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -449,6 +450,46 @@ describe("POST /api/nutrients/batch — write semantics", () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("marks a response carrying upsert_failed no-store, so a retry with the same Idempotency-Key writes again", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(prisma.nutrientIntakeDay.createMany).mockRejectedValue(
|
||||
new Error("connection reset"),
|
||||
);
|
||||
const failed = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(failed.status).toBe(200);
|
||||
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
spy.mockRestore();
|
||||
|
||||
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const clean = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
|
||||
});
|
||||
|
||||
it("documents upsert_failed as a transient write failure to retry, and the validation skips as terminal", () => {
|
||||
const description = String(
|
||||
(nutrientEntryResultSchema.meta() as { description?: string })
|
||||
.description,
|
||||
);
|
||||
expect(description).toMatch(/`upsert_failed` is a write failure/);
|
||||
expect(description).toMatch(/sent again on a later sync/);
|
||||
expect(description).not.toMatch(/log and drop a skipped entry/i);
|
||||
expect(description).toMatch(/unit_mismatch/);
|
||||
});
|
||||
|
||||
it("a race that drops one createMany row still counts consistently and is observable", async () => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
|
||||
count: 1,
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
* per-row updates (existing pairs), the measurements-batch shape. A write
|
||||
* failure marks its WHOLE group `skipped upsert_failed` rather than
|
||||
* isolating the one entry that would have caused it (unavoidable once the
|
||||
* write is batched) — the request itself never fails.
|
||||
* write is batched) — the request itself never fails. That reason is
|
||||
* transient, unlike the validation skips: the client sends those entries
|
||||
* again, and the response is marked `no-store` so an idempotent retry is not
|
||||
* answered from the cache.
|
||||
*
|
||||
* Module gate FIRST: the opt-in `nutrients` module (default off)
|
||||
* refuses ingest with the 403 `module.disabled` envelope, so a phone
|
||||
@@ -360,11 +363,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
const response = apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
// `upsert_failed` is transient: nothing was stored for those entries and the
|
||||
// client sends them again. `no-store` keeps the idempotency layer from
|
||||
// caching this answer, so a retry under the same key writes instead of
|
||||
// replaying the failure. The measurement batch does the same for `failed`.
|
||||
if (skippedByReason.upsert_failed) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
Reference in new issue
Block a user