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 @@
1import * as NodeCrypto from "node:crypto";
2
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
15import { describe, expect, it } from "@effect/vitest";
26import * as DateTime from "effect/DateTime";
37import * as Effect from "effect/Effect";
48import * 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";
8import { EnvironmentId, ThreadId } from "@t3tools/contracts";
14import type { ApnsCredentials } from "../Config.ts";
915import * as ApnsClient from "./ApnsClient.ts";
1016
17const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError);
18const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError);
19
1120const TestLayer = ApnsClient.layer.pipe(
1221 Layer.provide(
1322 Layer.succeed(
......@@ -137,4 +146,112 @@ describe("ApnsClient", () => {
137146 });
138147 }).pipe(Effect.provide(TestLayer)),
139148 );
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 });
140257});
infra/relay/src/agentActivity/ApnsClient.ts+113-30
......@@ -11,14 +11,17 @@ import * as Schema from "effect/Schema";
1111import * as Headers from "effect/unstable/http/Headers";
1212import * as HttpClient from "effect/unstable/http/HttpClient";
1313import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
14import type * as RelayConfiguration from "../Config.ts";
14import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts";
1515import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
1616
1717const LIVE_ACTIVITY_NAME = "AgentActivity";
1818const STALE_AFTER_SECONDS = 2 * 60;
1919const 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
2326interface ApnsLiveActivityRequest {
2427 readonly token: string;
......@@ -42,41 +45,48 @@ export interface ApnsDeliveryResult {
4245
4346export class ApnsJwtEncodingError extends Schema.TaggedErrorClass<ApnsJwtEncodingError>()(
4447 "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 },
4655) {
4756 override get message(): string {
48 return "Failed to encode APNs JWT.";
57 return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`;
4958 }
5059}
5160
5261export class ApnsJwtSigningError extends Schema.TaggedErrorClass<ApnsJwtSigningError>()(
5362 "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",
6363 {
64 teamId: Schema.String,
65 keyId: Schema.String,
66 issuedAtUnixSeconds: Schema.Number,
6467 cause: Schema.Defect(),
6568 },
6669) {
6770 override get message(): string {
68 return "APNs HTTP request failed.";
71 return `Failed to sign APNs JWT for key ${this.keyId}.`;
6972 }
7073}
7174
72export class ApnsInvalidResponseError extends Schema.TaggedErrorClass<ApnsInvalidResponseError>()(
73 "ApnsInvalidResponseError",
75export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()(
76 "ApnsHttpRequestError",
7477 {
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),
7585 cause: Schema.Defect(),
7686 },
7787) {
7888 override get message(): string {
79 return "APNs returned an invalid response.";
89 return `APNs ${this.requestKind} request failed during ${this.stage} in ${this.environment}.`;
8090 }
8191}
8292
......@@ -84,7 +94,6 @@ export const ApnsError = Schema.Union([
8494 ApnsJwtEncodingError,
8595 ApnsJwtSigningError,
8696 ApnsHttpRequestError,
87 ApnsInvalidResponseError,
8897]);
8998export type ApnsError = typeof ApnsError.Type;
9099
......@@ -113,18 +122,38 @@ const encodeApnsJwtPayloadJson = Schema.encodeEffect(
113122);
114123
115124const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: {
116 readonly teamId: RelayConfiguration.ApnsCredentials["teamId"];
117 readonly keyId: RelayConfiguration.ApnsCredentials["keyId"];
118 readonly privateKey: RelayConfiguration.ApnsCredentials["privateKey"];
125 readonly teamId: ApnsCredentials["teamId"];
126 readonly keyId: ApnsCredentials["keyId"];
127 readonly privateKey: ApnsCredentials["privateKey"];
119128 readonly issuedAtUnixSeconds: number;
120129}) {
121130 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 ),
123141 );
124142 const payloadJson = yield* encodeApnsJwtPayloadJson({
125143 iss: input.teamId,
126144 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
129158 const privateKey = Redacted.value(input.privateKey);
130159 const header = Encoding.encodeBase64Url(headerJson);
......@@ -141,7 +170,13 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: {
141170 });
142171 return `${signingInput}.${Encoding.encodeBase64Url(signature)}`;
143172 },
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 }),
145180 });
146181});
147182
......@@ -251,12 +286,12 @@ export class ApnsClient extends Context.Service<
251286 readonly makeLiveActivityRequest: typeof makeLiveActivityRequest;
252287 readonly makePushNotificationRequest: typeof makePushNotificationRequest;
253288 readonly sendLiveActivityRequest: (input: {
254 readonly credentials: RelayConfiguration.ApnsCredentials;
289 readonly credentials: ApnsCredentials;
255290 readonly request: ApnsLiveActivityRequest;
256291 readonly issuedAtUnixSeconds: number;
257292 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;
258293 readonly sendPushNotificationRequest: (input: {
259 readonly credentials: RelayConfiguration.ApnsCredentials;
294 readonly credentials: ApnsCredentials;
260295 readonly request: ApnsPushNotificationRequest;
261296 readonly issuedAtUnixSeconds: number;
262297 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;
......@@ -287,10 +322,34 @@ export const make = Effect.gen(function* () {
287322 }),
288323 HttpClientRequest.bodyJson(input.request.payload),
289324 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 ),
291338 );
292339 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 ),
294353 );
295354 const reason = apnsReasonFromBody(responseText);
296355 return {
......@@ -323,10 +382,34 @@ export const make = Effect.gen(function* () {
323382 }),
324383 HttpClientRequest.bodyJson(input.request.payload),
325384 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 ),
327398 );
328399 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 ),
330413 );
331414 const reason = apnsReasonFromBody(responseText);
332415 return {