feat(api): add data-import-batches manual retry endpoint

POST /api/data-import-batches/[id]/retry resets a failed or dead batch
back to pending (attempts=0, last_error/next_retry_at cleared) so the
next cron sweep picks it up. Non-retryable states return 409 with the
current status; legacy rows with no status default to processed.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DEls 2026-08-19 19:36:29 +02:00
parent c12c66a20a
commit c39124c637
2 changed files with 182 additions and 0 deletions

View file

@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
// Mock pbAdmin so the route doesn't hit a real PocketBase instance.
const getOneMock = vi.fn();
const updateMock = vi.fn();
vi.mock("@/lib/pb", () => ({
pbAdmin: vi.fn(async () => ({
collection: () => ({
getOne: getOneMock,
update: updateMock,
}),
})),
}));
import { POST } from "./route";
const ID = "abcdefghijklmno";
function post(id: string) {
return POST(new Request("http://x", { method: "POST" }), {
params: Promise.resolve({ id }),
});
}
describe("POST /api/data-import-batches/[id]/retry", () => {
beforeAll(() => {
vi.stubEnv("POCKETBASE_ADMIN_EMAIL", "test@example.com");
vi.stubEnv("POCKETBASE_ADMIN_PASSWORD", "test-password");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
getOneMock.mockReset();
updateMock.mockReset();
});
it("returns 400 for a bad batch id format", async () => {
const res = await post("not-a-pb-id");
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "bad_id" });
expect(getOneMock).not.toHaveBeenCalled();
});
it("returns 404 when the batch does not exist", async () => {
getOneMock.mockRejectedValueOnce(new Error("404 Not Found"));
const res = await post(ID);
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: "not_found" });
expect(updateMock).not.toHaveBeenCalled();
});
it("resets a failed batch back to pending", async () => {
getOneMock.mockResolvedValueOnce({ id: ID, status: "failed" });
updateMock.mockResolvedValueOnce({ id: ID });
const res = await post(ID);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: ID, status: "pending" });
expect(updateMock).toHaveBeenCalledWith(ID, {
status: "pending",
attempts: 0,
last_error: null,
next_retry_at: null,
});
});
it("resets a dead batch back to pending", async () => {
getOneMock.mockResolvedValueOnce({ id: ID, status: "dead" });
updateMock.mockResolvedValueOnce({ id: ID });
const res = await post(ID);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: ID, status: "pending" });
expect(updateMock).toHaveBeenCalledTimes(1);
});
it.each(["pending", "processing", "processed"])(
"returns 409 for a batch already in '%s'",
async (status) => {
getOneMock.mockResolvedValueOnce({ id: ID, status });
const res = await post(ID);
expect(res.status).toBe(409);
expect(await res.json()).toEqual({
error: "not_retryable",
current_status: status,
});
expect(updateMock).not.toHaveBeenCalled();
},
);
it("treats a legacy row with no status as processed and 409s", async () => {
getOneMock.mockResolvedValueOnce({ id: ID });
const res = await post(ID);
expect(res.status).toBe(409);
expect(await res.json()).toEqual({
error: "not_retryable",
current_status: "processed",
});
expect(updateMock).not.toHaveBeenCalled();
});
it("returns 500 on unexpected lookup errors", async () => {
getOneMock.mockRejectedValueOnce(new Error("connection refused"));
const res = await post(ID);
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "lookup_failed" });
expect(updateMock).not.toHaveBeenCalled();
});
it("returns 500 when the update fails", async () => {
getOneMock.mockResolvedValueOnce({ id: ID, status: "failed" });
updateMock.mockRejectedValueOnce(new Error("write conflict"));
const res = await post(ID);
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "update_failed" });
});
});

View file

@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const ID_PATTERN = /^[a-z0-9]{15}$/;
/**
* POST /api/data-import-batches/[id]/retry
*
* Resets a failed/dead batch back to pending so the next cron tick
* picks it up again. Idempotent for already-pending batches — but we
* 409 those because there's nothing to retry yet.
*/
export async function POST(
_request: Request,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
if (!ID_PATTERN.test(id)) {
return NextResponse.json({ error: "bad_id" }, { status: 400 });
}
const pb = await pbAdmin();
let batch: { status?: string };
try {
batch = await pb
.collection("data_import_batches")
.getOne(id, { fields: "id,status" });
} catch (err) {
if (err instanceof Error && /404/.test(err.message)) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
console.error("[batch-retry] lookup failed", { id, err: String(err) });
return NextResponse.json({ error: "lookup_failed" }, { status: 500 });
}
const current = batch.status ?? "processed";
if (current !== "failed" && current !== "dead") {
return NextResponse.json(
{
error: "not_retryable",
current_status: current,
},
{ status: 409 },
);
}
try {
await pb.collection("data_import_batches").update(id, {
status: "pending",
attempts: 0,
last_error: null,
next_retry_at: null,
});
return NextResponse.json({ id, status: "pending" });
} catch (err) {
console.error("[batch-retry] update failed", { id, err: String(err) });
return NextResponse.json({ error: "update_failed" }, { status: 500 });
}
}