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:
parent
c12c66a20a
commit
c39124c637
2 changed files with 182 additions and 0 deletions
119
src/app/api/data-import-batches/[id]/retry/route.test.ts
Normal file
119
src/app/api/data-import-batches/[id]/retry/route.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
63
src/app/api/data-import-batches/[id]/retry/route.ts
Normal file
63
src/app/api/data-import-batches/[id]/retry/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue