fix(apns): retry opposite gateway on BadEnvironmentKeyInToken (v1.4.47.5)

On BadEnvironmentKeyInToken, retry once with the opposite gateway. On success, persist Device.apnsEnvironment correction. Two new unit tests cover retry+persist and no-retry-on-BadDeviceToken.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-22 14:38:29 +02:00
1 parent 4e1e2bed32
commit 5803612e4d
4 files changed
+128 -9

No files matched your search

@@ -55,6 +55,7 @@ vi.mock("@/lib/db", () => ({
device: {
findMany: vi.fn(),
deleteMany: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
},
}));
@@ -489,6 +490,61 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(prisma.device.deleteMany).not.toHaveBeenCalled();
});
it("retries the opposite gateway on BadEnvironmentKeyInToken and corrects Device.apnsEnvironment", async () => {
// v1.4.47.5 — when Apple returns BadEnvironmentKeyInToken, the
// server's gateway choice (driven by Device.apnsEnvironment)
// doesn't match the token's actual environment. Try the other
// gateway exactly once; if it succeeds, persist the correction.
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "production" },
] as never);
// First send (production gateway): rejected.
sendMock.mockResolvedValueOnce({
sent: [],
failed: [{ device: "tok-a", status: 403, response: { reason: "BadEnvironmentKeyInToken" } }],
});
// Second send (sandbox gateway): success.
sendMock.mockResolvedValueOnce({
sent: [{ device: "tok-a" }],
failed: [],
});
const r = await sendViaApns("u-1", {
title: "t",
message: "m",
eventType: "MEDICATION_REMINDER",
});
expect(r.ok).toBe(true);
expect(sendMock).toHaveBeenCalledTimes(2);
expect(prisma.device.update).toHaveBeenCalledWith({
where: { id: "d1" },
data: { apnsEnvironment: "sandbox" },
});
});
it("does NOT retry the opposite gateway when reason is BadDeviceToken", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "production" },
] as never);
sendMock.mockResolvedValueOnce({
sent: [],
failed: [{ device: "tok-a", status: 410, response: { reason: "BadDeviceToken" } }],
});
const r = await sendViaApns("u-1", {
title: "t",
message: "m",
eventType: "MEDICATION_REMINDER",
});
expect(r.ok).toBe(false);
expect(r.hardReject).toBe(true);
// Only one send attempt — no retry on BadDeviceToken.
expect(sendMock).toHaveBeenCalledTimes(1);
expect(prisma.device.update).not.toHaveBeenCalled();
});
it("strips HTML from the alert body before handing to APNs", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
+52 -8
View File
@@ -562,9 +562,22 @@ export async function sendViaApns(
for (const device of devices) {
if (!device.apnsToken) continue;
const env =
const initialEnv: "production" | "sandbox" =
device.apnsEnvironment === "production" ? "production" : "sandbox";
// v1.4.47.5 — gateway auto-detect on BadEnvironmentKeyInToken.
// The iOS client picks the gateway env when it registers, but in
// practice the client's reported env can mismatch the token's
// actual environment (e.g. DEBUG build that registered itself as
// "production" by accident). Apple returns `BadEnvironmentKeyInToken`
// when the provider gateway env doesn't match the token env. Try
// the opposite gateway exactly once; if it succeeds, persist the
// correction so subsequent sends go straight to the right env.
const envSequence: ReadonlyArray<"production" | "sandbox"> =
initialEnv === "production"
? (["production", "sandbox"] as const)
: (["sandbox", "production"] as const);
// SB-5 v1.4.40 — only MEDICATION_REMINDER opts in to Focus-bypass.
// The user explicitly asked the server to nudge them about a dose;
// a missed dose is the textbook "time-sensitive" use case Apple
@@ -573,9 +586,12 @@ export async function sendViaApns(
// Focus modes — including Sleep — silence them as the user expects.
const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER";
const result = await sendApnsPush({
let result: Awaited<ReturnType<typeof sendApnsPush>> | null = null;
let workingEnv: "production" | "sandbox" | null = null;
for (const attemptEnv of envSequence) {
result = await sendApnsPush({
deviceToken: device.apnsToken,
environment: env,
environment: attemptEnv,
payload: {
alert: { title: payload.title, body: stripHtml(payload.message) },
data: {
@@ -602,18 +618,46 @@ export async function sendViaApns(
: {}),
},
collapseId: payload.eventType,
});
});
if (result.ok) {
if (result.ok) {
workingEnv = attemptEnv;
break;
}
// Only retry the OTHER gateway on the specific environment-mismatch
// reason. Other failure reasons (BadDeviceToken, expired, network
// blips) wouldn't be solved by switching gateways.
if (result.reason !== "BadEnvironmentKeyInToken") {
break;
}
}
if (result?.ok) {
anySuccess = true;
if (workingEnv && workingEnv !== initialEnv) {
// Persist the correction so subsequent sends go straight to the
// right gateway. Log a wide-event warning so the operator sees
// the iOS client's reported env was wrong.
await prisma.device
.update({
where: { id: device.id },
data: { apnsEnvironment: workingEnv },
})
.catch(() => {
/* persist is best-effort; the send already succeeded */
});
getEvent()?.addWarning(
`APNs device.apnsEnvironment corrected ${initialEnv} → ${workingEnv} after BadEnvironmentKeyInToken`,
);
}
continue;
}
if (result.shouldDisable) {
if (result?.shouldDisable) {
deadDeviceIds.push(device.id);
}
lastFailureReason = result.reason;
lastFailureStatus = result.status;
lastFailureReason = result?.reason;
lastFailureStatus = result?.status;
}
if (deadDeviceIds.length > 0) {