Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/user-sync-409-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@quranjs/api": patch
---

User Sync errors now carry the service's error code and message. A 409 `OutOfSyncError` from `client.auth.v1.sync` has `code: "OutOfSyncError"` and a message such as `409: Invalid lastMutationAt, please re-sync your data and try again.` instead of `409 `, for both the Gateway and direct backend response shapes. `isUserSyncOutOfSyncError` is unchanged.
18 changes: 10 additions & 8 deletions packages/api/src/runtime/user-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import type {
UserSyncSuccess,
} from "@/types";
import { QuranHttpError } from "@/sdk/http-error";
import { UserSyncPullRestartLimitError } from "@/sdk/user-sync-errors";
import {
UserSyncPullRestartLimitError,
withUserSyncErrorDetails,
} from "@/sdk/user-sync-errors";

interface UserSyncFetcher {
request<T>(
Expand Down Expand Up @@ -61,11 +64,8 @@ export const createUserSyncFacade = (
): Promise<UserSyncResponse<T>> => {
let mutationAt: number | null = null;
let status = 0;
const body = await fetcher.request<UserSyncSuccess<T>>(
"auth",
SYNC_PATH,
query,
{
const body = await fetcher
.request<UserSyncSuccess<T>>("auth", SYNC_PATH, query, {
...options,
auth: "user",
method,
Expand All @@ -74,8 +74,10 @@ export const createUserSyncFacade = (
status = metadata.status;
},
preserveResponseKeys: true,
},
);
})
.catch((error: unknown) => {
throw withUserSyncErrorDetails(error);
});

return { ...body, mutationAt, status };
};
Expand Down
74 changes: 74 additions & 0 deletions packages/api/src/sdk/user-sync-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type UserSyncOutOfSyncErrorPayload =
| { error: UserSyncServiceError; success: false };

export type UserSyncOutOfSyncError = QuranHttpError & {
/** Set on errors thrown by the User Sync facade. */
readonly code?: typeof USER_SYNC_OUT_OF_SYNC_ERROR_CODE;
readonly payload: UserSyncOutOfSyncErrorPayload;
readonly status: 409;
};
Expand All @@ -40,6 +42,78 @@ const readServiceErrorCode = (value: unknown): string | undefined => {
return typeof code === "string" ? code : undefined;
};

const readServiceError = (
value: unknown,
): { code?: string; message?: string } | undefined => {
if (typeof value !== "object" || value === null) {
return undefined;
}

const serviceError = (value as { error?: unknown }).error;
if (typeof serviceError !== "object" || serviceError === null) {
return undefined;
}

const { code, message } = serviceError as {
code?: unknown;
message?: unknown;
};
return {
code: typeof code === "string" ? code : undefined,
message: typeof message === "string" && message ? message : undefined,
};
};

const DEFAULT_OUT_OF_SYNC_MESSAGE =
"Out of sync: lastMutationAt is stale; pull and retry.";

/**
* Gives a User Sync `QuranHttpError` the service's error code and message.
*
* The shared HTTP error message is only `"<status> <statusText>"`, which is
* `"409 "` over HTTP/2, so the server's reason is otherwise lost. The error is
* updated in place, so `instanceof`, `status`, `headers` and `payload` stay as
* they were. Both the Gateway envelope (service error in `details.error`) and
* the direct backend envelope (service error in `error`) are read.
*/
export const withUserSyncErrorDetails = (error: unknown): unknown => {
if (!(error instanceof QuranHttpError)) {
return error;
}

const payload = error.payload;
if (typeof payload !== "object" || payload === null) {
return error;
}

const wrapped = readServiceError((payload as { details?: unknown }).details);
const direct = readServiceError(payload);
const code = wrapped?.code ?? direct?.code;
if (code === undefined) {
return error;
}

const topLevelMessage = (payload as { message?: unknown }).message;
const message =
wrapped?.message ??
direct?.message ??
(typeof topLevelMessage === "string" && topLevelMessage
? topLevelMessage
: undefined) ??
(isUserSyncOutOfSyncError(error) ? DEFAULT_OUT_OF_SYNC_MESSAGE : undefined);

Object.defineProperty(error, "code", {
configurable: true,
enumerable: true,
value: code,
writable: true,
});
if (message !== undefined) {
error.message = `${error.message.trimEnd()}: ${message}`;
}
return error;
};

/** Returns true for a 409 `OutOfSyncError` from `GET` or `POST /v1/sync`. */
export const isUserSyncOutOfSyncError = (
error: unknown,
Expand Down
90 changes: 90 additions & 0 deletions packages/api/test/user-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,96 @@ describe("User Sync facade", () => {
]);
expect(isUserSyncOutOfSyncError(new Error("409"))).toBe(false);
});

it("gives sync errors the service code and message for both envelopes", async () => {
// HTTP/2 responses carry no statusText, so the base message is "409 ".
const noStatusText = (body: unknown, status: number) =>
new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
const backendWithoutMessage = {
error: { code: "OutOfSyncError" },
success: false,
};
const responses = [
noStatusText(OUT_OF_SYNC_BODY, 409),
noStatusText(OUT_OF_SYNC_BODY.details, 409),
noStatusText(backendWithoutMessage, 409),
HttpResponse.json(
{
details: {
error: {
code: "UniqueViolationError",
message: "You already have a collection with this name",
},
success: false,
},
message: "You already have a collection with this name",
success: false,
type: "service_error",
},
{ status: 409, statusText: "Conflict" },
),
noStatusText({ message: "nope" }, 500),
];
// MSW fills in a default statusText, so serve these through `fetch`.
const client = createPublicClient({
clientId: "client-id",
clientType: "confidential-proxy",
fetch: () => Promise.resolve(responses.shift()!),
services: { authBaseUrl: "http://localhost:3001" },
userSession: { accessToken: "user-access-token" },
});

const errors: unknown[] = [];
for (let index = 0; index < 5; index += 1) {
errors.push(
await client.auth.v1.sync
.pushMutations({
lastMutationAt: 1,
mutations: [{ resource: "NOTE", resourceId: "x", type: "DELETE" }],
})
.catch((error: unknown) => error),
);
}

const [gateway, backend, bare, unique, other] = errors as QuranHttpError[];
const reason =
"Invalid lastMutationAt, please re-sync your data and try again.";
expect(gateway).toMatchObject({
code: "OutOfSyncError",
message: `409: ${reason}`,
payload: OUT_OF_SYNC_BODY,
status: 409,
});
expect(backend).toMatchObject({
code: "OutOfSyncError",
message: `409: ${reason}`,
payload: OUT_OF_SYNC_BODY.details,
status: 409,
});
expect(bare).toMatchObject({
code: "OutOfSyncError",
message: "409: Out of sync: lastMutationAt is stale; pull and retry.",
status: 409,
});
expect(unique).toMatchObject({
code: "UniqueViolationError",
message: "409 Conflict: You already have a collection with this name",
});
expect(other).toBeInstanceOf(QuranHttpError);
// Errors without a service code keep the shared message unchanged.
expect(other?.message).toBe("500 ");
expect(other).not.toHaveProperty("code");
expect(errors.map((error) => isUserSyncOutOfSyncError(error))).toEqual([
true,
true,
true,
false,
false,
]);
});
});

describe("User Sync pullAllMutations", () => {
Expand Down
Loading