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";
77import * as Layer from "effect/Layer";
88import * 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
1215import {
1316 sanitizeAgentActivityAggregateState,
......@@ -24,10 +27,17 @@ import * as RelayConfiguration from "../Config.ts";
2427
2528export class ApnsDeliveryQueueSendError extends Schema.TaggedErrorClass<ApnsDeliveryQueueSendError>()(
2629 "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 },
2838) {
2939 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}.`;
3141 }
3242}
3343
......@@ -36,7 +46,7 @@ export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError;
3646export class ApnsDeliveryQueueSender extends Context.Service<
3747 ApnsDeliveryQueueSender,
3848 {
39 readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect<void, ApnsDeliveryQueueSendError>;
49 readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect<void, Cloudflare.QueueSendError>;
4050 }
4151>()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {}
4252
......@@ -73,7 +83,17 @@ export const make = Effect.gen(function* () {
7383 });
7484 const now = yield* DateTime.now;
7585 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 ),
7797 );
7898 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId });
7999 const payload = makeApnsDeliveryJobPayload({
......@@ -88,7 +108,19 @@ export const make = Effect.gen(function* () {
88108 secret: config.apnsDeliveryJobSigningSecret,
89109 payload,
90110 });
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 );
92124 return {
93125 deviceId: input.deviceId,
94126 kind: input.kind,
......@@ -110,7 +142,17 @@ export const make = Effect.gen(function* () {
110142 });
111143 const now = yield* DateTime.now;
112144 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 ),
114156 );
115157 yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId });
116158 const payload = makeApnsDeliveryJobPayload({
......@@ -128,7 +170,19 @@ export const make = Effect.gen(function* () {
128170 secret: config.apnsDeliveryJobSigningSecret,
129171 payload,
130172 });
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 );
132186 return {
133187 deviceId: input.deviceId,
134188 kind: "push_notification" as const,
......@@ -155,10 +209,9 @@ export const layerCloudflareQueues = (
155209 ApnsDeliveryQueueSender,
156210 ApnsDeliveryQueueSender.of({
157211 send: (body) =>
158 sender.send(body).pipe(
159 Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })),
160 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
161 ),
212 sender
213 .send(body)
214 .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)),
162215 }),
163216 ),
164217 ),