History

[codex] Structure APNs delivery job errors (#3322)

Julius Marminge committed 4 months ago

Commit 5f860431 parent c9159b64 files · +221 −47Browse files

Unified · Split

4 files
  1. infra/relay/src/agentActivity/ApnsDeliveries.test.ts +16 −0
  2. infra/relay/src/agentActivity/ApnsDeliveries.ts +22 −3
  3. infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts +36 −5
  4. infra/relay/src/agentActivity/apnsDeliveryJobs.ts +147 −39
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+16−0
@@ -611,6 +611,22 @@ describe("ApnsDeliveries", () => { · 610 unmodified lines
611611 }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs })));
612612 });
613613
614+ it.effect("preserves the schema cause for invalid queue payloads", () => {
615+ const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
616+
617+ return Effect.gen(function* () {
618+ const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
619+ const error = yield* Effect.flip(deliveries.processSignedJob({ invalid: true }));
620+
621+ expect(error).toMatchObject({
622+ _tag: "ApnsDeliveryJobQueuePayloadInvalid",
623+ receivedType: "object",
624+ message: "Invalid APNs delivery queue job with object payload.",
625+ });
626+ expect(error.cause).toMatchObject({ _tag: "SchemaError" });
627+ }).pipe(Effect.provide(makeLayer({ attempts })));
628+ });
629+
614630 it.effect("processes signed jobs through APNs and records attempts", () => {
615631 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
616632 const payload = makeApnsDeliveryJobPayload({
infra/relay/src/agentActivity/ApnsDeliveries.ts+22−3
@@ -640,7 +640,13 @@ export const make = Effect.gen(function* () { · 639 unmodified lines
640640 "relay.apns_deliveries.process_signed_job",
641641 )(function* (body) {
642642 const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe(
643− Effect.mapError(() => new ApnsDeliveryJobQueuePayloadInvalid()),
643+ Effect.mapError(
644+ (cause) =>
645+ new ApnsDeliveryJobQueuePayloadInvalid({
646+ receivedType: Array.isArray(body) ? "array" : body === null ? "null" : typeof body,
647+ cause,
648+ }),
649+ ),
644650 );
645651 const now = yield* DateTime.now;
646652 const payload = verifySignedApnsDeliveryJob({
@@ -661,7 +667,14 @@ export const make = Effect.gen(function* () { · 14 unmodified lines
661667 case "live_activity_start":
662668 case "live_activity_update":
663669 if (payload.aggregate === null) {
664− return Effect.fail(new ApnsDeliveryJobLiveActivityAggregateMissing());
670+ return Effect.fail(
671+ new ApnsDeliveryJobLiveActivityAggregateMissing({
672+ jobId: payload.jobId,
673+ kind: payload.kind,
674+ userId: payload.target.userId,
675+ deviceId: payload.target.deviceId,
676+ }),
677+ );
665678 }
666679 return sendLiveActivity({
667680 target: {
@@ -686,7 +699,13 @@ export const make = Effect.gen(function* () { · 18 unmodified lines
686699 });
687700 case "push_notification":
688701 if (payload.notification === null) {
689− return Effect.fail(new ApnsDeliveryJobPushNotificationMissing());
702+ return Effect.fail(
703+ new ApnsDeliveryJobPushNotificationMissing({
704+ jobId: payload.jobId,
705+ userId: payload.target.userId,
706+ deviceId: payload.target.deviceId,
707+ }),
708+ );
690709 }
691710 return sendPushNotification({
692711 target: {
infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts+36−5
@@ -70,6 +70,11 @@ describe("apnsDeliveryJobs", () => { · 69 unmodified lines
7070
7171 expect(result).toMatchObject({
7272 _tag: "ApnsDeliveryJobSignatureInvalid",
73+ jobId: "job-1",
74+ kind: "live_activity_end",
75+ userId: "user-1",
76+ deviceId: "device-1",
77+ message: "Invalid signature for APNs delivery job job-1.",
7378 });
7479 });
7580
@@ -94,7 +99,11 @@ describe("apnsDeliveryJobs", () => { · 18 unmodified lines
9499
95100 expect(result).toMatchObject({
96101 _tag: "ApnsDeliveryJobLiveActivityAggregateMissing",
97− message: "Live Activity start/update jobs require an aggregate.",
102+ jobId: "job-start-invalid",
103+ kind: "live_activity_start",
104+ userId: "user-1",
105+ deviceId: "device-1",
106+ message: "APNs live activity start job job-start-invalid requires an aggregate.",
98107 });
99108 });
100109
@@ -120,7 +129,10 @@ describe("apnsDeliveryJobs", () => { · 19 unmodified lines
120129
121130 expect(result).toMatchObject({
122131 _tag: "ApnsDeliveryJobPushNotificationAggregateUnexpected",
123− message: "Push notification jobs must not carry aggregate state.",
132+ jobId: "job-push-invalid",
133+ userId: "user-1",
134+ deviceId: "device-1",
135+ message: "APNs push notification job job-push-invalid must not carry aggregate state.",
124136 });
125137 });
126138
@@ -195,7 +207,12 @@ describe("apnsDeliveryJobs", () => { · 68 unmodified lines
195207 }),
196208 ).toMatchObject({
197209 _tag: "ApnsDeliveryJobCreatedAtInvalid",
198− message: "Invalid APNs delivery job creation time.",
210+ jobId: "job-window",
211+ kind: "live_activity_end",
212+ userId: "user-1",
213+ deviceId: "device-1",
214+ createdAt: "not-a-date",
215+ message: "APNs delivery job job-window has invalid creation time not-a-date.",
199216 });
200217 expect(
201218 verifySignedApnsDeliveryJob({
@@ -205,7 +222,14 @@ describe("apnsDeliveryJobs", () => { · 3 unmodified lines
205222 }),
206223 ).toMatchObject({
207224 _tag: "ApnsDeliveryJobTimeWindowInvalid",
208− message: "Invalid APNs delivery job time window.",
225+ jobId: "job-window",
226+ kind: "live_activity_end",
227+ userId: "user-1",
228+ deviceId: "device-1",
229+ createdAt: "2026-05-25T00:00:00.000Z",
230+ expiresAt: "2026-05-24T23:59:59.000Z",
231+ message:
232+ "APNs delivery job job-window has invalid time window 2026-05-25T00:00:00.000Z to 2026-05-24T23:59:59.000Z.",
209233 });
210234 expect(
211235 verifySignedApnsDeliveryJob({
@@ -215,7 +239,14 @@ describe("apnsDeliveryJobs", () => { · 3 unmodified lines
215239 }),
216240 ).toMatchObject({
217241 _tag: "ApnsDeliveryJobTimeWindowTooLong",
218− message: "APNs delivery job time window is too long.",
242+ jobId: "job-window",
243+ kind: "live_activity_end",
244+ userId: "user-1",
245+ deviceId: "device-1",
246+ createdAt: "2026-05-25T00:00:00.000Z",
247+ expiresAt: "2026-05-25T00:10:01.000Z",
248+ message:
249+ "APNs delivery job job-window time window 2026-05-25T00:00:00.000Z to 2026-05-25T00:10:01.000Z is too long.",
219250 });
220251 });
221252 });
infra/relay/src/agentActivity/apnsDeliveryJobs.ts+147−39
@@ -10,12 +10,27 @@ import * as Schema from "effect/Schema"; · 9 unmodified lines
1010 const MAX_JOB_AGE_MS = 10 * 60 * 1_000;
1111 export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256";
1212
13−const ApnsDeliveryKind = Schema.Literals([
13+const ApnsDeliveryKindSchema = Schema.Literals([
1414 "live_activity_start",
1515 "live_activity_update",
1616 "live_activity_end",
1717 "push_notification",
1818 ]);
19+const LiveActivityStartOrUpdateKindSchema = Schema.Literals([
20+ "live_activity_start",
21+ "live_activity_update",
22+]);
23+const LiveActivityKindSchema = Schema.Literals([
24+ "live_activity_start",
25+ "live_activity_update",
26+ "live_activity_end",
27+]);
28+
29+const ApnsDeliveryJobContext = {
30+ jobId: Schema.String,
31+ userId: Schema.String,
32+ deviceId: Schema.String,
33+};
1934
2035 export const ApnsNotificationPayload = Schema.Struct({
2136 title: Schema.String,
@@ -29,7 +44,7 @@ export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; · 7 unmodified lines
2944 export const ApnsDeliveryJobPayload = Schema.Struct({
3045 version: Schema.Literal(1),
3146 jobId: Schema.String,
32− kind: ApnsDeliveryKind,
47+ kind: ApnsDeliveryKindSchema,
3348 target: Schema.Struct({
3449 userId: Schema.String,
3550 deviceId: Schema.String,
@@ -51,91 +66,121 @@ export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; · 15 unmodified lines
5166
5267 export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobQueuePayloadInvalid>()(
5368 "ApnsDeliveryJobQueuePayloadInvalid",
54− {},
69+ {
70+ receivedType: Schema.String,
71+ cause: Schema.Defect(),
72+ },
5573 ) {
5674 override get message(): string {
57− return "Invalid APNs delivery queue job.";
75+ return `Invalid APNs delivery queue job with ${this.receivedType} payload.`;
5876 }
5977 }
6078
6179 export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityAggregateMissing>()(
6280 "ApnsDeliveryJobLiveActivityAggregateMissing",
63− {},
81+ {
82+ ...ApnsDeliveryJobContext,
83+ kind: LiveActivityStartOrUpdateKindSchema,
84+ },
6485 ) {
6586 override get message(): string {
66− return "Live Activity start/update jobs require an aggregate.";
87+ return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} requires an aggregate.`;
6788 }
6889 }
6990
7091 export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityNotificationUnexpected>()(
7192 "ApnsDeliveryJobLiveActivityNotificationUnexpected",
72− {},
93+ {
94+ ...ApnsDeliveryJobContext,
95+ kind: LiveActivityKindSchema,
96+ },
7397 ) {
7498 override get message(): string {
75− return "Live Activity jobs must not carry push notification payloads.";
99+ return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} must not carry a push notification payload.`;
76100 }
77101 }
78102
79103 export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationMissing>()(
80104 "ApnsDeliveryJobPushNotificationMissing",
81− {},
105+ ApnsDeliveryJobContext,
82106 ) {
83107 override get message(): string {
84− return "Push notification jobs require a notification payload.";
108+ return `APNs push notification job ${this.jobId} requires a notification payload.`;
85109 }
86110 }
87111
88112 export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationAggregateUnexpected>()(
89113 "ApnsDeliveryJobPushNotificationAggregateUnexpected",
90− {},
114+ ApnsDeliveryJobContext,
91115 ) {
92116 override get message(): string {
93− return "Push notification jobs must not carry aggregate state.";
117+ return `APNs push notification job ${this.jobId} must not carry aggregate state.`;
94118 }
95119 }
96120
97121 export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobCreatedAtInvalid>()(
98122 "ApnsDeliveryJobCreatedAtInvalid",
99− {},
123+ {
124+ ...ApnsDeliveryJobContext,
125+ kind: ApnsDeliveryKindSchema,
126+ createdAt: Schema.String,
127+ },
100128 ) {
101129 override get message(): string {
102− return "Invalid APNs delivery job creation time.";
130+ return `APNs delivery job ${this.jobId} has invalid creation time ${this.createdAt}.`;
103131 }
104132 }
105133
106134 export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobExpiresAtInvalid>()(
107135 "ApnsDeliveryJobExpiresAtInvalid",
108− {},
136+ {
137+ ...ApnsDeliveryJobContext,
138+ kind: ApnsDeliveryKindSchema,
139+ expiresAt: Schema.String,
140+ },
109141 ) {
110142 override get message(): string {
111− return "Invalid APNs delivery job expiry.";
143+ return `APNs delivery job ${this.jobId} has invalid expiry ${this.expiresAt}.`;
112144 }
113145 }
114146
115147 export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowInvalid>()(
116148 "ApnsDeliveryJobTimeWindowInvalid",
117− {},
149+ {
150+ ...ApnsDeliveryJobContext,
151+ kind: ApnsDeliveryKindSchema,
152+ createdAt: Schema.String,
153+ expiresAt: Schema.String,
154+ },
118155 ) {
119156 override get message(): string {
120− return "Invalid APNs delivery job time window.";
157+ return `APNs delivery job ${this.jobId} has invalid time window ${this.createdAt} to ${this.expiresAt}.`;
121158 }
122159 }
123160
124161 export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowTooLong>()(
125162 "ApnsDeliveryJobTimeWindowTooLong",
126− {},
163+ {
164+ ...ApnsDeliveryJobContext,
165+ kind: ApnsDeliveryKindSchema,
166+ createdAt: Schema.String,
167+ expiresAt: Schema.String,
168+ },
127169 ) {
128170 override get message(): string {
129− return "APNs delivery job time window is too long.";
171+ return `APNs delivery job ${this.jobId} time window ${this.createdAt} to ${this.expiresAt} is too long.`;
130172 }
131173 }
132174
133175 export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobSignatureInvalid>()(
134176 "ApnsDeliveryJobSignatureInvalid",
135− {},
177+ {
178+ ...ApnsDeliveryJobContext,
179+ kind: ApnsDeliveryKindSchema,
180+ },
136181 ) {
137182 override get message(): string {
138− return "Invalid APNs delivery job signature.";
183+ return `Invalid signature for APNs delivery job ${this.jobId}.`;
139184 }
140185 }
141186
@@ -156,11 +201,13 @@ export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type; · 14 unmodified lines
156201 export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass<ApnsDeliveryJobExpired>()(
157202 "ApnsDeliveryJobExpired",
158203 {
204+ ...ApnsDeliveryJobContext,
205+ kind: ApnsDeliveryKindSchema,
159206 expiresAt: Schema.String,
160207 },
161208 ) {
162209 override get message(): string {
163− return `APNs delivery job expired at ${this.expiresAt}`;
210+ return `APNs delivery job ${this.jobId} expired at ${this.expiresAt}.`;
164211 }
165212 }
166213
@@ -208,23 +255,46 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI · 41 unmodified lines
208255 case "live_activity_start":
209256 case "live_activity_update":
210257 if (payload.aggregate === null) {
211− return new ApnsDeliveryJobLiveActivityAggregateMissing();
258+ return new ApnsDeliveryJobLiveActivityAggregateMissing({
259+ jobId: payload.jobId,
260+ kind: payload.kind,
261+ userId: payload.target.userId,
262+ deviceId: payload.target.deviceId,
263+ });
212264 }
213265 if (payload.notification !== null) {
214− return new ApnsDeliveryJobLiveActivityNotificationUnexpected();
266+ return new ApnsDeliveryJobLiveActivityNotificationUnexpected({
267+ jobId: payload.jobId,
268+ kind: payload.kind,
269+ userId: payload.target.userId,
270+ deviceId: payload.target.deviceId,
271+ });
215272 }
216273 return null;
217274 case "live_activity_end":
218275 if (payload.notification !== null) {
219− return new ApnsDeliveryJobLiveActivityNotificationUnexpected();
276+ return new ApnsDeliveryJobLiveActivityNotificationUnexpected({
277+ jobId: payload.jobId,
278+ kind: payload.kind,
279+ userId: payload.target.userId,
280+ deviceId: payload.target.deviceId,
281+ });
220282 }
221283 return null;
222284 case "push_notification":
223285 if (payload.notification === null) {
224− return new ApnsDeliveryJobPushNotificationMissing();
286+ return new ApnsDeliveryJobPushNotificationMissing({
287+ jobId: payload.jobId,
288+ userId: payload.target.userId,
289+ deviceId: payload.target.deviceId,
290+ });
225291 }
226292 if (payload.aggregate !== null) {
227− return new ApnsDeliveryJobPushNotificationAggregateUnexpected();
293+ return new ApnsDeliveryJobPushNotificationAggregateUnexpected({
294+ jobId: payload.jobId,
295+ userId: payload.target.userId,
296+ deviceId: payload.target.deviceId,
297+ });
228298 }
229299 return null;
230300 }
@@ -264,35 +334,73 @@ export function verifySignedApnsDeliveryJob(input: { · 33 unmodified lines
264334 readonly job: SignedApnsDeliveryJob;
265335 readonly nowMs: number;
266336 }): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError {
267− const invalidPayload = validatePayloadShape(input.job.payload);
337+ const payload = input.job.payload;
338+ const invalidPayload = validatePayloadShape(payload);
268339 if (invalidPayload !== null) {
269340 return invalidPayload;
270341 }
271− const createdAt = DateTime.make(input.job.payload.createdAt);
342+ const createdAt = DateTime.make(payload.createdAt);
272343 if (Option.isNone(createdAt)) {
273− return new ApnsDeliveryJobCreatedAtInvalid();
344+ return new ApnsDeliveryJobCreatedAtInvalid({
345+ jobId: payload.jobId,
346+ kind: payload.kind,
347+ userId: payload.target.userId,
348+ deviceId: payload.target.deviceId,
349+ createdAt: payload.createdAt,
350+ });
274351 }
275− const expiresAt = DateTime.make(input.job.payload.expiresAt);
352+ const expiresAt = DateTime.make(payload.expiresAt);
276353 if (Option.isNone(expiresAt)) {
277− return new ApnsDeliveryJobExpiresAtInvalid();
354+ return new ApnsDeliveryJobExpiresAtInvalid({
355+ jobId: payload.jobId,
356+ kind: payload.kind,
357+ userId: payload.target.userId,
358+ deviceId: payload.target.deviceId,
359+ expiresAt: payload.expiresAt,
360+ });
278361 }
279362 const createdAtMs = createdAt.value.epochMilliseconds;
280363 const expiresAtMs = expiresAt.value.epochMilliseconds;
281364 if (expiresAtMs <= createdAtMs) {
282− return new ApnsDeliveryJobTimeWindowInvalid();
365+ return new ApnsDeliveryJobTimeWindowInvalid({
366+ jobId: payload.jobId,
367+ kind: payload.kind,
368+ userId: payload.target.userId,
369+ deviceId: payload.target.deviceId,
370+ createdAt: payload.createdAt,
371+ expiresAt: payload.expiresAt,
372+ });
283373 }
284374 if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) {
285− return new ApnsDeliveryJobTimeWindowTooLong();
375+ return new ApnsDeliveryJobTimeWindowTooLong({
376+ jobId: payload.jobId,
377+ kind: payload.kind,
378+ userId: payload.target.userId,
379+ deviceId: payload.target.deviceId,
380+ createdAt: payload.createdAt,
381+ expiresAt: payload.expiresAt,
382+ });
286383 }
287384 if (expiresAtMs <= input.nowMs) {
288− return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt });
385+ return new ApnsDeliveryJobExpired({
386+ jobId: payload.jobId,
387+ kind: payload.kind,
388+ userId: payload.target.userId,
389+ deviceId: payload.target.deviceId,
390+ expiresAt: payload.expiresAt,
391+ });
289392 }
290393 const expected = signatureForPayload({
291394 secret: input.secret,
292− payload: input.job.payload,
395+ payload,
293396 });
294397 if (!timingSafeEqualBase64Url(input.job.signature, expected)) {
295− return new ApnsDeliveryJobSignatureInvalid();
398+ return new ApnsDeliveryJobSignatureInvalid({
399+ jobId: payload.jobId,
400+ kind: payload.kind,
401+ userId: payload.target.userId,
402+ deviceId: payload.target.deviceId,
403+ });
296404 }
297− return input.job.payload;
405+ return payload;
298406 }