authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:03:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:03:43-07:00
logc9159b67b30340e12f75005e34faa7d1aca0f231
tree5a69bfe75794681f7e96b7af52deec8c75e7e7f7
parenta1b727f62d56edd0e43c1d0a74142626fc7673c5
signature Signed by PGP key B5690EEEBB952194

[codex] Structure APNs client errors (#3318)

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

2 files changed, 233 insertions(+), 33 deletions(-)

infra/relay/src/agentActivity/ApnsClient.test.ts+120-3
...@@ -1,13 +1,22 @@...@@ -1,13 +1,22 @@
1import * as NodeCrypto from "node:crypto";
2
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
1import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
5import { HttpClient } from "effect/unstable/http";9import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";
11import * as HttpClient from "effect/unstable/http/HttpClient";
12import * as HttpClientError from "effect/unstable/http/HttpClientError";
613
7import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";14import type { ApnsCredentials } from "../Config.ts";
8import { EnvironmentId, ThreadId } from "@t3tools/contracts";
9import * as ApnsClient from "./ApnsClient.ts";15import * as ApnsClient from "./ApnsClient.ts";
1016
17const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError);
18const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError);
19
11const TestLayer = ApnsClient.layer.pipe(20const TestLayer = ApnsClient.layer.pipe(
12 Layer.provide(21 Layer.provide(
13 Layer.succeed(22 Layer.succeed(
...@@ -137,4 +146,112 @@ describe("ApnsClient", () => {...@@ -137,4 +146,112 @@ describe("ApnsClient", () => {
137 });146 });
138 }).pipe(Effect.provide(TestLayer)),147 }).pipe(Effect.provide(TestLayer)),
139 );148 );
149
150 it.effect("preserves JWT signing context and the crypto cause", () =>
151 Effect.gen(function* () {
152 const apns = yield* ApnsClient.ApnsClient;
153 const request = apns.makePushNotificationRequest({
154 token: "push-token",
155 notification: {
156 title: "Thread",
157 body: "Input: Project",
158 environmentId: "env",
159 threadId: "thread",
160 deepLink: "/threads/env/thread",
161 },
162 });
163 const error = yield* Effect.flip(
164 apns.sendPushNotificationRequest({
165 credentials: {
166 teamId: "team-1",
167 keyId: "key-1",
168 privateKey: Redacted.make("not-a-private-key"),
169 bundleId: "com.t3tools.test",
170 environment: "sandbox",
171 },
172 request,
173 issuedAtUnixSeconds: 123,
174 }),
175 );
176
177 expect(isApnsJwtSigningError(error)).toBe(true);
178 if (!isApnsJwtSigningError(error)) {
179 return yield* Effect.die("expected APNs JWT signing error");
180 }
181 expect(error).toMatchObject({
182 teamId: "team-1",
183 keyId: "key-1",
184 issuedAtUnixSeconds: 123,
185 cause: expect.any(Error),
186 message: "Failed to sign APNs JWT for key key-1.",
187 });
188 }).pipe(Effect.provide(TestLayer)),
189 );
190
191 it.effect("preserves APNs request context and the HTTP cause", () => {
192 const httpCause = new Error("network unavailable");
193 const { privateKey } = NodeCrypto.generateKeyPairSync("ec", {
194 namedCurve: "prime256v1",
195 privateKeyEncoding: { type: "pkcs8", format: "pem" },
196 publicKeyEncoding: { type: "spki", format: "pem" },
197 });
198 const credentials = {
199 teamId: "team-1",
200 keyId: "key-1",
201 privateKey: Redacted.make(privateKey),
202 bundleId: "com.t3tools.test",
203 environment: "sandbox",
204 } satisfies ApnsCredentials;
205 const failingHttpClient = HttpClient.make((request) =>
206 Effect.fail(
207 new HttpClientError.HttpClientError({
208 reason: new HttpClientError.TransportError({ request, cause: httpCause }),
209 }),
210 ),
211 );
212 const layer = ApnsClient.layer.pipe(
213 Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)),
214 );
215
216 return Effect.gen(function* () {
217 const apns = yield* ApnsClient.ApnsClient;
218 const request = apns.makePushNotificationRequest({
219 token: "long-push-token",
220 notification: {
221 title: "Thread",
222 body: "Input: Project",
223 environmentId: "env",
224 threadId: "thread",
225 deepLink: "/threads/env/thread",
226 },
227 });
228 const error = yield* Effect.flip(
229 apns.sendPushNotificationRequest({
230 credentials,
231 request,
232 issuedAtUnixSeconds: 123,
233 }),
234 );
235
236 expect(isApnsHttpRequestError(error)).toBe(true);
237 if (!isApnsHttpRequestError(error)) {
238 return yield* Effect.die("expected APNs HTTP request error");
239 }
240 expect(error).toMatchObject({
241 requestKind: "push-notification",
242 event: null,
243 environment: "sandbox",
244 bundleId: "com.t3tools.test",
245 tokenSuffix: "sh-token",
246 stage: "send",
247 status: null,
248 message: "APNs push-notification request failed during send in sandbox.",
249 });
250 expect(error.cause).toBeInstanceOf(HttpClientError.HttpClientError);
251 expect((error.cause as HttpClientError.HttpClientError).reason).toMatchObject({
252 _tag: "TransportError",
253 cause: httpCause,
254 });
255 }).pipe(Effect.provide(layer));
256 });
140});257});
infra/relay/src/agentActivity/ApnsClient.ts+113-30
...@@ -11,14 +11,17 @@ import * as Schema from "effect/Schema";...@@ -11,14 +11,17 @@ import * as Schema from "effect/Schema";
11import * as Headers from "effect/unstable/http/Headers";11import * as Headers from "effect/unstable/http/Headers";
12import * as HttpClient from "effect/unstable/http/HttpClient";12import * as HttpClient from "effect/unstable/http/HttpClient";
13import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";13import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
14import type * as RelayConfiguration from "../Config.ts";14import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts";
15import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";15import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
1616
17const LIVE_ACTIVITY_NAME = "AgentActivity";17const LIVE_ACTIVITY_NAME = "AgentActivity";
18const STALE_AFTER_SECONDS = 2 * 60;18const STALE_AFTER_SECONDS = 2 * 60;
19const DISMISS_AFTER_SECONDS = 5 * 60;19const DISMISS_AFTER_SECONDS = 5 * 60;
2020
21export type ApnsLiveActivityEvent = "start" | "update" | "end";21const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]);
22export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type;
23
24const ApnsRequestKindSchema = Schema.Literals(["live-activity", "push-notification"]);
2225
23interface ApnsLiveActivityRequest {26interface ApnsLiveActivityRequest {
24 readonly token: string;27 readonly token: string;
...@@ -42,41 +45,48 @@ export interface ApnsDeliveryResult {...@@ -42,41 +45,48 @@ export interface ApnsDeliveryResult {
4245
43export class ApnsJwtEncodingError extends Schema.TaggedErrorClass<ApnsJwtEncodingError>()(46export class ApnsJwtEncodingError extends Schema.TaggedErrorClass<ApnsJwtEncodingError>()(
44 "ApnsJwtEncodingError",47 "ApnsJwtEncodingError",
45 { cause: Schema.Defect() },48 {
49 component: Schema.Literals(["header", "payload"]),
50 teamId: Schema.String,
51 keyId: Schema.String,
52 issuedAtUnixSeconds: Schema.Number,
53 cause: Schema.Defect(),
54 },
46) {55) {
47 override get message(): string {56 override get message(): string {
48 return "Failed to encode APNs JWT.";57 return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`;
49 }58 }
50}59}
5160
52export class ApnsJwtSigningError extends Schema.TaggedErrorClass<ApnsJwtSigningError>()(61export class ApnsJwtSigningError extends Schema.TaggedErrorClass<ApnsJwtSigningError>()(
53 "ApnsJwtSigningError",62 "ApnsJwtSigningError",
54 { cause: Schema.Defect() },
55) {
56 override get message(): string {
57 return "Failed to sign APNs JWT.";
58 }
59}
60
61export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()(
62 "ApnsHttpRequestError",
63 {63 {
64 teamId: Schema.String,
65 keyId: Schema.String,
66 issuedAtUnixSeconds: Schema.Number,
64 cause: Schema.Defect(),67 cause: Schema.Defect(),
65 },68 },
66) {69) {
67 override get message(): string {70 override get message(): string {
68 return "APNs HTTP request failed.";71 return `Failed to sign APNs JWT for key ${this.keyId}.`;
69 }72 }
70}73}
7174
72export class ApnsInvalidResponseError extends Schema.TaggedErrorClass<ApnsInvalidResponseError>()(75export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()(
73 "ApnsInvalidResponseError",76 "ApnsHttpRequestError",
74 {77 {
78 requestKind: ApnsRequestKindSchema,
79 event: Schema.NullOr(ApnsLiveActivityEventSchema),
80 environment: ApnsEnvironmentSchema,
81 bundleId: Schema.String,
82 tokenSuffix: Schema.String,
83 stage: Schema.Literals(["send", "read-response"]),
84 status: Schema.NullOr(Schema.Number),
75 cause: Schema.Defect(),85 cause: Schema.Defect(),
76 },86 },
77) {87) {
78 override get message(): string {88 override get message(): string {
79 return "APNs returned an invalid response.";89 return `APNs ${this.requestKind} request failed during ${this.stage} in ${this.environment}.`;
80 }90 }
81}91}
8292
...@@ -84,7 +94,6 @@ export const ApnsError = Schema.Union([...@@ -84,7 +94,6 @@ export const ApnsError = Schema.Union([
84 ApnsJwtEncodingError,94 ApnsJwtEncodingError,
85 ApnsJwtSigningError,95 ApnsJwtSigningError,
86 ApnsHttpRequestError,96 ApnsHttpRequestError,
87 ApnsInvalidResponseError,
88]);97]);
89export type ApnsError = typeof ApnsError.Type;98export type ApnsError = typeof ApnsError.Type;
9099
...@@ -113,18 +122,38 @@ const encodeApnsJwtPayloadJson = Schema.encodeEffect(...@@ -113,18 +122,38 @@ const encodeApnsJwtPayloadJson = Schema.encodeEffect(
113);122);
114123
115const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: {124const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: {
116 readonly teamId: RelayConfiguration.ApnsCredentials["teamId"];125 readonly teamId: ApnsCredentials["teamId"];
117 readonly keyId: RelayConfiguration.ApnsCredentials["keyId"];126 readonly keyId: ApnsCredentials["keyId"];
118 readonly privateKey: RelayConfiguration.ApnsCredentials["privateKey"];127 readonly privateKey: ApnsCredentials["privateKey"];
119 readonly issuedAtUnixSeconds: number;128 readonly issuedAtUnixSeconds: number;
120}) {129}) {
121 const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe(130 const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe(
122 Effect.mapError((cause) => new ApnsJwtEncodingError({ cause })),131 Effect.mapError(
132 (cause) =>
133 new ApnsJwtEncodingError({
134 component: "header",
135 teamId: input.teamId,
136 keyId: input.keyId,
137 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
138 cause,
139 }),
140 ),
123 );141 );
124 const payloadJson = yield* encodeApnsJwtPayloadJson({142 const payloadJson = yield* encodeApnsJwtPayloadJson({
125 iss: input.teamId,143 iss: input.teamId,
126 iat: input.issuedAtUnixSeconds,144 iat: input.issuedAtUnixSeconds,
127 }).pipe(Effect.mapError((cause) => new ApnsJwtEncodingError({ cause })));145 }).pipe(
146 Effect.mapError(
147 (cause) =>
148 new ApnsJwtEncodingError({
149 component: "payload",
150 teamId: input.teamId,
151 keyId: input.keyId,
152 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
153 cause,
154 }),
155 ),
156 );
128157
129 const privateKey = Redacted.value(input.privateKey);158 const privateKey = Redacted.value(input.privateKey);
130 const header = Encoding.encodeBase64Url(headerJson);159 const header = Encoding.encodeBase64Url(headerJson);
...@@ -141,7 +170,13 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: {...@@ -141,7 +170,13 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: {
141 });170 });
142 return `${signingInput}.${Encoding.encodeBase64Url(signature)}`;171 return `${signingInput}.${Encoding.encodeBase64Url(signature)}`;
143 },172 },
144 catch: (cause) => new ApnsJwtSigningError({ cause }),173 catch: (cause) =>
174 new ApnsJwtSigningError({
175 teamId: input.teamId,
176 keyId: input.keyId,
177 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
178 cause,
179 }),
145 });180 });
146});181});
147182
...@@ -251,12 +286,12 @@ export class ApnsClient extends Context.Service<...@@ -251,12 +286,12 @@ export class ApnsClient extends Context.Service<
251 readonly makeLiveActivityRequest: typeof makeLiveActivityRequest;286 readonly makeLiveActivityRequest: typeof makeLiveActivityRequest;
252 readonly makePushNotificationRequest: typeof makePushNotificationRequest;287 readonly makePushNotificationRequest: typeof makePushNotificationRequest;
253 readonly sendLiveActivityRequest: (input: {288 readonly sendLiveActivityRequest: (input: {
254 readonly credentials: RelayConfiguration.ApnsCredentials;289 readonly credentials: ApnsCredentials;
255 readonly request: ApnsLiveActivityRequest;290 readonly request: ApnsLiveActivityRequest;
256 readonly issuedAtUnixSeconds: number;291 readonly issuedAtUnixSeconds: number;
257 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;292 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;
258 readonly sendPushNotificationRequest: (input: {293 readonly sendPushNotificationRequest: (input: {
259 readonly credentials: RelayConfiguration.ApnsCredentials;294 readonly credentials: ApnsCredentials;
260 readonly request: ApnsPushNotificationRequest;295 readonly request: ApnsPushNotificationRequest;
261 readonly issuedAtUnixSeconds: number;296 readonly issuedAtUnixSeconds: number;
262 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;297 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;
...@@ -287,10 +322,34 @@ export const make = Effect.gen(function* () {...@@ -287,10 +322,34 @@ export const make = Effect.gen(function* () {
287 }),322 }),
288 HttpClientRequest.bodyJson(input.request.payload),323 HttpClientRequest.bodyJson(input.request.payload),
289 Effect.flatMap(httpClient.execute),324 Effect.flatMap(httpClient.execute),
290 Effect.mapError((cause) => new ApnsHttpRequestError({ cause })),325 Effect.mapError(
326 (cause) =>
327 new ApnsHttpRequestError({
328 requestKind: "live-activity",
329 event: input.request.event,
330 environment: input.credentials.environment,
331 bundleId: input.credentials.bundleId,
332 tokenSuffix: input.request.token.slice(-8),
333 stage: "send",
334 status: null,
335 cause,
336 }),
337 ),
291 );338 );
292 const responseText = yield* response.text.pipe(339 const responseText = yield* response.text.pipe(
293 Effect.mapError((cause) => new ApnsHttpRequestError({ cause })),340 Effect.mapError(
341 (cause) =>
342 new ApnsHttpRequestError({
343 requestKind: "live-activity",
344 event: input.request.event,
345 environment: input.credentials.environment,
346 bundleId: input.credentials.bundleId,
347 tokenSuffix: input.request.token.slice(-8),
348 stage: "read-response",
349 status: response.status,
350 cause,
351 }),
352 ),
294 );353 );
295 const reason = apnsReasonFromBody(responseText);354 const reason = apnsReasonFromBody(responseText);
296 return {355 return {
...@@ -323,10 +382,34 @@ export const make = Effect.gen(function* () {...@@ -323,10 +382,34 @@ export const make = Effect.gen(function* () {
323 }),382 }),
324 HttpClientRequest.bodyJson(input.request.payload),383 HttpClientRequest.bodyJson(input.request.payload),
325 Effect.flatMap(httpClient.execute),384 Effect.flatMap(httpClient.execute),
326 Effect.mapError((cause) => new ApnsHttpRequestError({ cause })),385 Effect.mapError(
386 (cause) =>
387 new ApnsHttpRequestError({
388 requestKind: "push-notification",
389 event: null,
390 environment: input.credentials.environment,
391 bundleId: input.credentials.bundleId,
392 tokenSuffix: input.request.token.slice(-8),
393 stage: "send",
394 status: null,
395 cause,
396 }),
397 ),
327 );398 );
328 const responseText = yield* response.text.pipe(399 const responseText = yield* response.text.pipe(
329 Effect.mapError((cause) => new ApnsHttpRequestError({ cause })),400 Effect.mapError(
401 (cause) =>
402 new ApnsHttpRequestError({
403 requestKind: "push-notification",
404 event: null,
405 environment: input.credentials.environment,
406 bundleId: input.credentials.bundleId,
407 tokenSuffix: input.request.token.slice(-8),
408 stage: "read-response",
409 status: response.status,
410 cause,
411 }),
412 ),
330 );413 );
331 const reason = apnsReasonFromBody(responseText);414 const reason = apnsReasonFromBody(responseText);
332 return {415 return {