authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 20:20:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 20:20:01-07:00
loge3970e755b0da65c3fd83a985cede1840164ae0b
treef3bb3cd5321f331fd5d1d9046ce4cb527d9561ea
parent3c246f56b477e0d7c39f92cfc0a69ca3f6debd4b
signature Signed by PGP key B5690EEEBB952194

[codex] Preserve APNs delivery failure context (#3475)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 124 insertions(+), 17 deletions(-)

infra/relay/src/agentActivity/ApnsDeliveries.test.ts+36-1
......@@ -7,7 +7,9 @@ import { describe, expect, it } from "@effect/vitest";
77import * as NodeCrypto from "node:crypto";
88import * as Effect from "effect/Effect";
99import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";
1011import * as Redacted from "effect/Redacted";
12import * as References from "effect/References";
1113import {
1214 FetchHttpClient,
1315 HttpClient,
......@@ -629,6 +631,17 @@ describe("ApnsDeliveries", () => {
629631
630632 it.effect("processes signed jobs through APNs and records attempts", () => {
631633 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
634 const transportErrors: Array<ApnsDeliveries.ApnsDeliveryTransportError> = [];
635 const logger = Logger.make(({ fiber }) => {
636 const annotation = fiber.getRef(References.CurrentLogAnnotations).error;
637 if (!Redacted.isRedacted(annotation)) {
638 return;
639 }
640 const error = Redacted.value(annotation);
641 if (ApnsDeliveries.isApnsDeliveryTransportError(error)) {
642 transportErrors.push(error);
643 }
644 });
632645 const payload = makeApnsDeliveryJobPayload({
633646 kind: "live_activity_update",
634647 userId: target.user_id,
......@@ -657,7 +670,29 @@ describe("ApnsDeliveries", () => {
657670 token: "activity-token",
658671 },
659672 ]);
660 }).pipe(Effect.provide(makeLayer({ attempts })));
673 expect(transportErrors).toHaveLength(1);
674 const error = transportErrors[0]!;
675 expect(error).toMatchObject({
676 deviceId: target.device_id,
677 kind: "live_activity_update",
678 sourceJobId: "job-1",
679 apnsErrorTag: "ApnsJwtSigningError",
680 requestStage: null,
681 });
682 expect(error.cause).toBeInstanceOf(ApnsClient.ApnsJwtSigningError);
683 expect(error.cause).toMatchObject({
684 teamId: "team-id",
685 keyId: "key-id",
686 });
687 expect((error.cause as ApnsClient.ApnsJwtSigningError).cause).toBeDefined();
688 }).pipe(
689 Effect.provide(
690 Layer.mergeAll(
691 makeLayer({ attempts }),
692 Logger.layer([logger], { mergeWithExisting: false }),
693 ),
694 ),
695 );
661696 });
662697
663698 it.effect("processes signed push notification jobs through APNs and records attempts", () => {
infra/relay/src/agentActivity/ApnsDeliveries.ts+88-16
......@@ -7,12 +7,14 @@ import type {
77import {
88 RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema,
99 RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema,
10 RelayDeliveryKind as RelayDeliveryKindSchema,
1011} from "@t3tools/contracts/relay";
1112import * as Context from "effect/Context";
1213import * as DateTime from "effect/DateTime";
1314import * as Effect from "effect/Effect";
1415import * as Layer from "effect/Layer";
1516import * as Option from "effect/Option";
17import * as Redacted from "effect/Redacted";
1618import * as Schema from "effect/Schema";
1719
1820import {
......@@ -87,6 +89,28 @@ export class ApnsDeliveryJobClaimInFlight extends Schema.TaggedErrorClass<ApnsDe
8789 }
8890}
8991
92export class ApnsDeliveryTransportError extends Schema.TaggedErrorClass<ApnsDeliveryTransportError>()(
93 "ApnsDeliveryTransportError",
94 {
95 deviceId: Schema.String,
96 kind: RelayDeliveryKindSchema,
97 sourceJobId: Schema.NullOr(Schema.String),
98 apnsErrorTag: Schema.Literals([
99 "ApnsJwtEncodingError",
100 "ApnsJwtSigningError",
101 "ApnsHttpRequestError",
102 ]),
103 requestStage: Schema.NullOr(Schema.Literals(["send", "read-response"])),
104 cause: Schema.Defect(),
105 },
106) {
107 override get message(): string {
108 return `APNs ${this.kind} delivery failed for device ${this.deviceId}.`;
109 }
110}
111
112export const isApnsDeliveryTransportError = Schema.is(ApnsDeliveryTransportError);
113
90114const decodeRelayAgentActivityAggregateStateJson = Schema.decodeUnknownOption(
91115 Schema.fromJsonString(RelayAgentActivityAggregateStateSchema),
92116);
......@@ -302,6 +326,42 @@ function deliveryAttemptOutcome(result: Apns.ApnsDeliveryResult) {
302326 };
303327}
304328
329const recoverApnsDeliveryTransportError = (
330 input: {
331 readonly deviceId: string;
332 readonly kind: RelayDeliveryKind;
333 readonly sourceJobId: string | null;
334 },
335 cause: Apns.ApnsError,
336): Effect.Effect<Apns.ApnsDeliveryResult> => {
337 const error = new ApnsDeliveryTransportError({
338 deviceId: input.deviceId,
339 kind: input.kind,
340 sourceJobId: input.sourceJobId,
341 apnsErrorTag: cause._tag,
342 requestStage: cause._tag === "ApnsHttpRequestError" ? cause.stage : null,
343 cause,
344 });
345 return Effect.logError(error.message).pipe(
346 Effect.annotateLogs({
347 error: Redacted.make(error, { label: error._tag }),
348 "error.type": error._tag,
349 "error.apns_error_tag": error.apnsErrorTag,
350 ...(error.requestStage === null ? {} : { "error.request_stage": error.requestStage }),
351 ...(error.stack === undefined ? {} : { "error.stack": error.stack }),
352 "relay.mobile.device_id": error.deviceId,
353 "relay.delivery.kind": error.kind,
354 ...(error.sourceJobId === null ? {} : { "relay.delivery.job_id": error.sourceJobId }),
355 }),
356 Effect.as({
357 ok: false,
358 status: 0,
359 reason: cause.message,
360 apnsId: null,
361 }),
362 );
363};
364
305365interface LiveActivityDeliveryTarget {
306366 readonly user_id: string;
307367 readonly device_id: string;
......@@ -440,6 +500,15 @@ export const make = Effect.gen(function* () {
440500 { ...input, aggregate } as SendLiveActivityDeliveryInput,
441501 now,
442502 );
503 const recoverTransportError = (cause: Apns.ApnsError) =>
504 recoverApnsDeliveryTransportError(
505 {
506 deviceId: input.target.device_id,
507 kind: input.kind,
508 sourceJobId: input.sourceJobId ?? null,
509 },
510 cause,
511 );
443512 if (input.sourceJobId) {
444513 const claim = yield* attempts.claimSourceJob({
445514 userId: input.target.user_id,
......@@ -476,14 +545,11 @@ export const make = Effect.gen(function* () {
476545 issuedAtUnixSeconds: epochSeconds,
477546 })
478547 .pipe(
479 Effect.catch((error) =>
480 Effect.succeed({
481 ok: false,
482 status: 0,
483 reason: error.message,
484 apnsId: null,
485 }),
486 ),
548 Effect.catchTags({
549 ApnsJwtEncodingError: recoverTransportError,
550 ApnsJwtSigningError: recoverTransportError,
551 ApnsHttpRequestError: recoverTransportError,
552 }),
487553 );
488554 if (result.ok) {
489555 yield* liveActivities.markDelivery({
......@@ -551,6 +617,15 @@ export const make = Effect.gen(function* () {
551617 token: input.token,
552618 notification,
553619 });
620 const recoverTransportError = (cause: Apns.ApnsError) =>
621 recoverApnsDeliveryTransportError(
622 {
623 deviceId: input.target.device_id,
624 kind: "push_notification",
625 sourceJobId: input.sourceJobId ?? null,
626 },
627 cause,
628 );
554629 if (input.sourceJobId) {
555630 const claim = yield* attempts.claimSourceJob({
556631 userId: input.target.user_id,
......@@ -593,14 +668,11 @@ export const make = Effect.gen(function* () {
593668 issuedAtUnixSeconds: epochSeconds,
594669 })
595670 .pipe(
596 Effect.catch((error) =>
597 Effect.succeed({
598 ok: false,
599 status: 0,
600 reason: error.message,
601 apnsId: null,
602 }),
603 ),
671 Effect.catchTags({
672 ApnsJwtEncodingError: recoverTransportError,
673 ApnsJwtSigningError: recoverTransportError,
674 ApnsHttpRequestError: recoverTransportError,
675 }),
604676 );
605677 if (isPermanentApnsTokenFailure(result)) {
606678 yield* liveActivities.invalidateDeliveryToken({