authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:36:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:36:08-07:00
log32c7f90d9e6d4045a709b2a0faf30b977bdf26da
treeafe3c594da6aa83e54776b1c880658758dae8884
parent2c16edd0cb1169ce916e3da6a736d371954532a1
signature Signed by PGP key B5690EEEBB952194

[codex] Structure APNs delivery queue errors (#3326)

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

2 files changed, 144 insertions(+), 12 deletions(-)

infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts created+79
...@@ -0,0 +1,79 @@
1import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
2import { describe, expect, it } from "@effect/vitest";
3import * as Cloudflare from "alchemy/Cloudflare";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7
8import * as RelayConfiguration from "../Config.ts";
9import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
10
11const config: RelayConfiguration.RelayConfiguration["Service"] = {
12 relayIssuer: "https://relay.example.com",
13 apns: {
14 teamId: "team-1",
15 keyId: "key-1",
16 privateKey: Redacted.make("apns-private-key"),
17 bundleId: "com.t3tools.test",
18 environment: "sandbox",
19 },
20 clerkSecretKey: Redacted.make("clerk-secret"),
21 clerkPublishableKey: "pk_test_test",
22 clerkJwtAudience: "t3-code-relay",
23 apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"),
24 cloudMintPrivateKey: Redacted.make("cloud-private-key"),
25 cloudMintPublicKey: "cloud-public-key",
26 managedEndpointBaseDomain: undefined,
27 managedEndpointNamespace: undefined,
28};
29
30describe("ApnsDeliveryQueue", () => {
31 it.effect("preserves job identity and the queue sender cause", () => {
32 const cause = new Error("queue unavailable");
33 const senderCause = new Cloudflare.QueueSendError({
34 message: cause.message,
35 cause,
36 });
37 const layer = ApnsDeliveryQueue.layer.pipe(
38 Layer.provide(NodeCryptoLayer.layer),
39 Layer.provide(RelayConfiguration.layer(config)),
40 Layer.provide(
41 Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, {
42 send: () => Effect.fail(senderCause),
43 }),
44 ),
45 );
46
47 return Effect.gen(function* () {
48 const queue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue;
49 const error = yield* Effect.flip(
50 queue.enqueuePushNotification({
51 userId: "user-1",
52 deviceId: "device-1",
53 token: "push-token",
54 notification: {
55 title: "Thread",
56 body: "Input: Project",
57 environmentId: "env-1",
58 threadId: "thread-1",
59 deepLink: "/threads/env-1/thread-1",
60 },
61 }),
62 );
63
64 expect(error).toMatchObject({
65 _tag: "ApnsDeliveryQueueSendError",
66 operation: "send",
67 jobId: expect.any(String),
68 kind: "push_notification",
69 userId: "user-1",
70 deviceId: "device-1",
71 cause: senderCause,
72 });
73 expect(senderCause.cause).toBe(cause);
74 expect(error.message).toBe(
75 "Failed to enqueue APNs push notification delivery during send for device device-1.",
76 );
77 }).pipe(Effect.provide(layer));
78 });
79});
infra/relay/src/agentActivity/ApnsDeliveryQueue.ts+65-12
...@@ -7,7 +7,10 @@ import * as Effect from "effect/Effect";...@@ -7,7 +7,10 @@ import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
99
10import type { RelayDeliveryResult } from "@t3tools/contracts/relay";10import {
11 RelayDeliveryKind as RelayDeliveryKindSchema,
12 type RelayDeliveryResult,
13} from "@t3tools/contracts/relay";
1114
12import {15import {
13 sanitizeAgentActivityAggregateState,16 sanitizeAgentActivityAggregateState,
...@@ -24,10 +27,17 @@ import * as RelayConfiguration from "../Config.ts";...@@ -24,10 +27,17 @@ import * as RelayConfiguration from "../Config.ts";
2427
25export class ApnsDeliveryQueueSendError extends Schema.TaggedErrorClass<ApnsDeliveryQueueSendError>()(28export class ApnsDeliveryQueueSendError extends Schema.TaggedErrorClass<ApnsDeliveryQueueSendError>()(
26 "ApnsDeliveryQueueSendError",29 "ApnsDeliveryQueueSendError",
27 { cause: Schema.Defect() },30 {
31 operation: Schema.Literals(["generate-job-id", "send"]),
32 jobId: Schema.NullOr(Schema.String),
33 kind: RelayDeliveryKindSchema,
34 userId: Schema.String,
35 deviceId: Schema.String,
36 cause: Schema.Defect(),
37 },
28) {38) {
29 override get message(): string {39 override get message(): string {
30 return "Failed to enqueue APNs delivery";40 return `Failed to enqueue APNs ${this.kind.replaceAll("_", " ")} delivery during ${this.operation} for device ${this.deviceId}.`;
31 }41 }
32}42}
3343
...@@ -36,7 +46,7 @@ export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError;...@@ -36,7 +46,7 @@ export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError;
36export class ApnsDeliveryQueueSender extends Context.Service<46export class ApnsDeliveryQueueSender extends Context.Service<
37 ApnsDeliveryQueueSender,47 ApnsDeliveryQueueSender,
38 {48 {
39 readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect<void, ApnsDeliveryQueueSendError>;49 readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect<void, Cloudflare.QueueSendError>;
40 }50 }
41>()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {}51>()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {}
4252
...@@ -73,7 +83,17 @@ export const make = Effect.gen(function* () {...@@ -73,7 +83,17 @@ export const make = Effect.gen(function* () {
73 });83 });
74 const now = yield* DateTime.now;84 const now = yield* DateTime.now;
75 const jobId = yield* crypto.randomUUIDv4.pipe(85 const jobId = yield* crypto.randomUUIDv4.pipe(
76 Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })),86 Effect.mapError(
87 (cause) =>
88 new ApnsDeliveryQueueSendError({
89 operation: "generate-job-id",
90 jobId: null,
91 kind: input.kind,
92 userId: input.userId,
93 deviceId: input.deviceId,
94 cause,
95 }),
96 ),
77 );97 );
78 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId });98 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId });
79 const payload = makeApnsDeliveryJobPayload({99 const payload = makeApnsDeliveryJobPayload({
...@@ -88,7 +108,19 @@ export const make = Effect.gen(function* () {...@@ -88,7 +108,19 @@ export const make = Effect.gen(function* () {
88 secret: config.apnsDeliveryJobSigningSecret,108 secret: config.apnsDeliveryJobSigningSecret,
89 payload,109 payload,
90 });110 });
91 yield* sender.send(signed);111 yield* sender.send(signed).pipe(
112 Effect.mapError(
113 (cause) =>
114 new ApnsDeliveryQueueSendError({
115 operation: "send",
116 jobId,
117 kind: input.kind,
118 userId: input.userId,
119 deviceId: input.deviceId,
120 cause,
121 }),
122 ),
123 );
92 return {124 return {
93 deviceId: input.deviceId,125 deviceId: input.deviceId,
94 kind: input.kind,126 kind: input.kind,
...@@ -110,7 +142,17 @@ export const make = Effect.gen(function* () {...@@ -110,7 +142,17 @@ export const make = Effect.gen(function* () {
110 });142 });
111 const now = yield* DateTime.now;143 const now = yield* DateTime.now;
112 const jobId = yield* crypto.randomUUIDv4.pipe(144 const jobId = yield* crypto.randomUUIDv4.pipe(
113 Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })),145 Effect.mapError(
146 (cause) =>
147 new ApnsDeliveryQueueSendError({
148 operation: "generate-job-id",
149 jobId: null,
150 kind: "push_notification",
151 userId: input.userId,
152 deviceId: input.deviceId,
153 cause,
154 }),
155 ),
114 );156 );
115 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId });157 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId });
116 const payload = makeApnsDeliveryJobPayload({158 const payload = makeApnsDeliveryJobPayload({
...@@ -128,7 +170,19 @@ export const make = Effect.gen(function* () {...@@ -128,7 +170,19 @@ export const make = Effect.gen(function* () {
128 secret: config.apnsDeliveryJobSigningSecret,170 secret: config.apnsDeliveryJobSigningSecret,
129 payload,171 payload,
130 });172 });
131 yield* sender.send(signed);173 yield* sender.send(signed).pipe(
174 Effect.mapError(
175 (cause) =>
176 new ApnsDeliveryQueueSendError({
177 operation: "send",
178 jobId,
179 kind: "push_notification",
180 userId: input.userId,
181 deviceId: input.deviceId,
182 cause,
183 }),
184 ),
185 );
132 return {186 return {
133 deviceId: input.deviceId,187 deviceId: input.deviceId,
134 kind: "push_notification" as const,188 kind: "push_notification" as const,
...@@ -155,10 +209,9 @@ export const layerCloudflareQueues = (...@@ -155,10 +209,9 @@ export const layerCloudflareQueues = (
155 ApnsDeliveryQueueSender,209 ApnsDeliveryQueueSender,
156 ApnsDeliveryQueueSender.of({210 ApnsDeliveryQueueSender.of({
157 send: (body) =>211 send: (body) =>
158 sender.send(body).pipe(212 sender
159 Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })),213 .send(body)
160 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),214 .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)),
161 ),
162 }),215 }),
163 ),216 ),
164 ),217 ),