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";...@@ -7,7 +7,9 @@ import { describe, expect, it } from "@effect/vitest";
7import * as NodeCrypto from "node:crypto";7import * as NodeCrypto from "node:crypto";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";
10import * as Redacted from "effect/Redacted";11import * as Redacted from "effect/Redacted";
12import * as References from "effect/References";
11import {13import {
12 FetchHttpClient,14 FetchHttpClient,
13 HttpClient,15 HttpClient,
...@@ -629,6 +631,17 @@ describe("ApnsDeliveries", () => {...@@ -629,6 +631,17 @@ describe("ApnsDeliveries", () => {
629631
630 it.effect("processes signed jobs through APNs and records attempts", () => {632 it.effect("processes signed jobs through APNs and records attempts", () => {
631 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];633 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 });
632 const payload = makeApnsDeliveryJobPayload({645 const payload = makeApnsDeliveryJobPayload({
633 kind: "live_activity_update",646 kind: "live_activity_update",
634 userId: target.user_id,647 userId: target.user_id,
...@@ -657,7 +670,29 @@ describe("ApnsDeliveries", () => {...@@ -657,7 +670,29 @@ describe("ApnsDeliveries", () => {
657 token: "activity-token",670 token: "activity-token",
658 },671 },
659 ]);672 ]);
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 );
661 });696 });
662697
663 it.effect("processes signed push notification jobs through APNs and records attempts", () => {698 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 {...@@ -7,12 +7,14 @@ import type {
7import {7import {
8 RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema,8 RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema,
9 RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema,9 RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema,
10 RelayDeliveryKind as RelayDeliveryKindSchema,
10} from "@t3tools/contracts/relay";11} from "@t3tools/contracts/relay";
11import * as Context from "effect/Context";12import * as Context from "effect/Context";
12import * as DateTime from "effect/DateTime";13import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";16import * as Option from "effect/Option";
17import * as Redacted from "effect/Redacted";
16import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
1719
18import {20import {
...@@ -87,6 +89,28 @@ export class ApnsDeliveryJobClaimInFlight extends Schema.TaggedErrorClass<ApnsDe...@@ -87,6 +89,28 @@ export class ApnsDeliveryJobClaimInFlight extends Schema.TaggedErrorClass<ApnsDe
87 }89 }
88}90}
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
90const decodeRelayAgentActivityAggregateStateJson = Schema.decodeUnknownOption(114const decodeRelayAgentActivityAggregateStateJson = Schema.decodeUnknownOption(
91 Schema.fromJsonString(RelayAgentActivityAggregateStateSchema),115 Schema.fromJsonString(RelayAgentActivityAggregateStateSchema),
92);116);
...@@ -302,6 +326,42 @@ function deliveryAttemptOutcome(result: Apns.ApnsDeliveryResult) {...@@ -302,6 +326,42 @@ function deliveryAttemptOutcome(result: Apns.ApnsDeliveryResult) {
302 };326 };
303}327}
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
305interface LiveActivityDeliveryTarget {365interface LiveActivityDeliveryTarget {
306 readonly user_id: string;366 readonly user_id: string;
307 readonly device_id: string;367 readonly device_id: string;
...@@ -440,6 +500,15 @@ export const make = Effect.gen(function* () {...@@ -440,6 +500,15 @@ export const make = Effect.gen(function* () {
440 { ...input, aggregate } as SendLiveActivityDeliveryInput,500 { ...input, aggregate } as SendLiveActivityDeliveryInput,
441 now,501 now,
442 );502 );
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 );
443 if (input.sourceJobId) {512 if (input.sourceJobId) {
444 const claim = yield* attempts.claimSourceJob({513 const claim = yield* attempts.claimSourceJob({
445 userId: input.target.user_id,514 userId: input.target.user_id,
...@@ -476,14 +545,11 @@ export const make = Effect.gen(function* () {...@@ -476,14 +545,11 @@ export const make = Effect.gen(function* () {
476 issuedAtUnixSeconds: epochSeconds,545 issuedAtUnixSeconds: epochSeconds,
477 })546 })
478 .pipe(547 .pipe(
479 Effect.catch((error) =>548 Effect.catchTags({
480 Effect.succeed({549 ApnsJwtEncodingError: recoverTransportError,
481 ok: false,550 ApnsJwtSigningError: recoverTransportError,
482 status: 0,551 ApnsHttpRequestError: recoverTransportError,
483 reason: error.message,552 }),
484 apnsId: null,
485 }),
486 ),
487 );553 );
488 if (result.ok) {554 if (result.ok) {
489 yield* liveActivities.markDelivery({555 yield* liveActivities.markDelivery({
...@@ -551,6 +617,15 @@ export const make = Effect.gen(function* () {...@@ -551,6 +617,15 @@ export const make = Effect.gen(function* () {
551 token: input.token,617 token: input.token,
552 notification,618 notification,
553 });619 });
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 );
554 if (input.sourceJobId) {629 if (input.sourceJobId) {
555 const claim = yield* attempts.claimSourceJob({630 const claim = yield* attempts.claimSourceJob({
556 userId: input.target.user_id,631 userId: input.target.user_id,
...@@ -593,14 +668,11 @@ export const make = Effect.gen(function* () {...@@ -593,14 +668,11 @@ export const make = Effect.gen(function* () {
593 issuedAtUnixSeconds: epochSeconds,668 issuedAtUnixSeconds: epochSeconds,
594 })669 })
595 .pipe(670 .pipe(
596 Effect.catch((error) =>671 Effect.catchTags({
597 Effect.succeed({672 ApnsJwtEncodingError: recoverTransportError,
598 ok: false,673 ApnsJwtSigningError: recoverTransportError,
599 status: 0,674 ApnsHttpRequestError: recoverTransportError,
600 reason: error.message,675 }),
601 apnsId: null,
602 }),
603 ),
604 );676 );
605 if (isPermanentApnsTokenFailure(result)) {677 if (isPermanentApnsTokenFailure(result)) {
606 yield* liveActivities.invalidateDeliveryToken({678 yield* liveActivities.invalidateDeliveryToken({