Skip to content

Commit 5677603

Browse files
authored
fix(pds): return 400 RecordNotFound and treat deleteRecord as no-op (#189)
* fix(pds): return 400 RecordNotFound and treat deleteRecord as no-op Match the reference @atproto PDS: getRecord raises InvalidRequestError (HTTP 400) with RecordNotFound, and deleteRecord on a missing record is a 200 no-op rather than an error. Adds a pdscheck for the getRecord status code. * test(pds): update e2e tests for new RecordNotFound semantics
1 parent 838001e commit 5677603

5 files changed

Lines changed: 102 additions & 36 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@getcirrus/pds": patch
3+
---
4+
5+
Align `com.atproto.repo.getRecord` and `com.atproto.repo.deleteRecord` error handling with the reference @atproto PDS:
6+
7+
- `getRecord` now returns HTTP 400 (not 404) with `RecordNotFound` when the record is missing. The reference PDS raises `InvalidRequestError`, which maps to 400.
8+
- `deleteRecord` on a missing record is now a 200 no-op (returning an empty body with no commit) instead of `RecordNotFound`, matching the reference PDS.

apps/check/src/checks/repo-read.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,80 @@ const getRecordValidates: Check = {
564564
},
565565
};
566566

567+
const getRecordMissing: Check = {
568+
id: "repo-read.get-record-missing",
569+
category: "repo-read",
570+
label: "getRecord returns 400 RecordNotFound for missing record",
571+
description:
572+
"Matches the reference @atproto PDS, which raises InvalidRequestError (HTTP 400) with error 'RecordNotFound' rather than returning a 404. Clients that probe a record before writing it (for example detaching a quote, which checks app.bsky.feed.postgate first) rely on this.",
573+
requires: ["pds", "did"],
574+
run: async (ctx): Promise<CheckOutcome> => {
575+
const pds = ctx.pds!;
576+
const did = ctx.did!;
577+
const collection = sampleRecord?.collection ?? "app.bsky.feed.post";
578+
const rkey = `pdscheck-missing-${Date.now().toString(36)}`;
579+
const url = xrpcUrl(pds, "com.atproto.repo.getRecord", {
580+
repo: did,
581+
collection,
582+
rkey,
583+
});
584+
try {
585+
const res = await fetch(url);
586+
let body: unknown;
587+
try {
588+
body = await res.json();
589+
} catch {
590+
body = await res.text().catch(() => undefined);
591+
}
592+
if (res.status !== 400) {
593+
return {
594+
status: "fail",
595+
message: `Expected 400, got ${res.status}`,
596+
evidence: {
597+
request: { method: "GET", url },
598+
response: { status: res.status, body },
599+
expected: 400,
600+
actual: res.status,
601+
},
602+
};
603+
}
604+
const error =
605+
body && typeof body === "object" && "error" in body
606+
? (body as { error: unknown }).error
607+
: undefined;
608+
if (error !== "RecordNotFound") {
609+
return {
610+
status: "fail",
611+
message: `Expected error 'RecordNotFound', got ${JSON.stringify(error)}`,
612+
evidence: {
613+
request: { method: "GET", url },
614+
response: { status: res.status, body },
615+
expected: "RecordNotFound",
616+
actual: error,
617+
},
618+
};
619+
}
620+
return {
621+
status: "pass",
622+
message: "400 RecordNotFound",
623+
evidence: {
624+
request: { method: "GET", url },
625+
response: { status: res.status, body },
626+
},
627+
};
628+
} catch (error) {
629+
return {
630+
status: "fail",
631+
message: error instanceof Error ? error.message : String(error),
632+
evidence: {
633+
request: { method: "GET", url },
634+
error: String(error),
635+
},
636+
};
637+
}
638+
},
639+
};
640+
567641
const getRepoCarValidates: Check = {
568642
id: "repo-read.get-repo-car.validates",
569643
category: "repo-read",
@@ -635,6 +709,7 @@ export const repoReadChecks: Check[] = [
635709
listRecordsValidates,
636710
getRecord,
637711
getRecordValidates,
712+
getRecordMissing,
638713
listRecordsCursor,
639714
getRepoCar,
640715
getRepoCarValidates,

packages/pds/e2e/crud.e2e.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,14 @@ describe("CRUD Operations", () => {
8787
expect(getResult.data.cid).toBe(createResult.data.cid);
8888
});
8989

90-
it("returns 404 for non-existent record", async () => {
90+
it("returns RecordNotFound for non-existent record", async () => {
9191
await expect(
9292
agent.com.atproto.repo.getRecord({
9393
repo: TEST_DID,
9494
collection: "app.bsky.feed.post",
9595
rkey: "non-existent-rkey-12345",
9696
}),
97-
).rejects.toThrow();
97+
).rejects.toMatchObject({ error: "RecordNotFound", status: 400 });
9898
});
9999
});
100100

@@ -188,16 +188,14 @@ describe("CRUD Operations", () => {
188188
).rejects.toThrow();
189189
});
190190

191-
it("throws when deleting non-existent record", async () => {
192-
// AT Protocol spec allows this to throw or no-op
193-
// Our implementation throws RecordNotFound
194-
await expect(
195-
agent.com.atproto.repo.deleteRecord({
196-
repo: TEST_DID,
197-
collection: "app.bsky.feed.post",
198-
rkey: "non-existent-rkey-67890",
199-
}),
200-
).rejects.toThrow();
191+
it("treats deleting a non-existent record as a no-op", async () => {
192+
const res = await agent.com.atproto.repo.deleteRecord({
193+
repo: TEST_DID,
194+
collection: "app.bsky.feed.post",
195+
rkey: "non-existent-rkey-67890",
196+
});
197+
expect(res.success).toBe(true);
198+
expect(res.data.commit).toBeUndefined();
201199
});
202200
});
203201

packages/pds/src/xrpc/repo.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export async function getRecord(
194194
error: "RecordNotFound",
195195
message: `Record not found: ${collection}/${rkey}`,
196196
},
197-
404,
197+
400,
198198
);
199199
}
200200

@@ -372,17 +372,7 @@ export async function deleteRecord(
372372
try {
373373
const result = await accountDO.rpcDeleteRecord(collection, rkey);
374374

375-
if (!result) {
376-
return c.json(
377-
{
378-
error: "RecordNotFound",
379-
message: `Record not found: ${collection}/${rkey}`,
380-
},
381-
404,
382-
);
383-
}
384-
385-
return c.json(result);
375+
return c.json(result ?? {});
386376
} catch (err) {
387377
const deactivatedError = checkAccountDeactivatedError(c, err);
388378
if (deactivatedError) return deactivatedError;

packages/pds/test/xrpc.test.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -631,10 +631,10 @@ describe("XRPC Endpoints", () => {
631631
),
632632
env,
633633
);
634-
expect(getResponse.status).toBe(404);
634+
expect(getResponse.status).toBe(400);
635635
});
636636

637-
it("should handle deleting non-existent record", async () => {
637+
it("should handle deleting non-existent record as a no-op", async () => {
638638
const response = await worker.fetch(
639639
new Request("http://pds.test/xrpc/com.atproto.repo.deleteRecord", {
640640
method: "POST",
@@ -650,22 +650,17 @@ describe("XRPC Endpoints", () => {
650650
}),
651651
env,
652652
);
653-
expect(response.status).toBe(404);
654-
655-
const data = await response.json();
656-
expect(data).toMatchObject({
657-
error: "RecordNotFound",
658-
});
653+
expect(response.status).toBe(200);
659654
});
660655

661-
it("should return 404 for non-existent record", async () => {
656+
it("should return 400 RecordNotFound for non-existent record", async () => {
662657
const response = await worker.fetch(
663658
new Request(
664659
`http://pds.test/xrpc/com.atproto.repo.getRecord?repo=${env.DID}&collection=app.bsky.feed.post&rkey=does-not-exist`,
665660
),
666661
env,
667662
);
668-
expect(response.status).toBe(404);
663+
expect(response.status).toBe(400);
669664

670665
const data = await response.json();
671666
expect(data).toMatchObject({
@@ -1032,7 +1027,7 @@ describe("XRPC Endpoints", () => {
10321027
),
10331028
env,
10341029
);
1035-
expect(getResponse.status).toBe(404);
1030+
expect(getResponse.status).toBe(400);
10361031
});
10371032

10381033
it("should handle mixed operations", async () => {
@@ -1306,7 +1301,7 @@ describe("XRPC Endpoints", () => {
13061301
),
13071302
env,
13081303
);
1309-
expect(getResponse.status).toBe(404);
1304+
expect(getResponse.status).toBe(400);
13101305
});
13111306

13121307
it("rejects two creates for the same rkey as 409 RecordAlreadyExists", async () => {

0 commit comments

Comments
 (0)