History

Merge remote-tracking branch 'origin/codex/batch13-workos-vault-errors' into codex/effect-lint-integration

Rhys Sullivan committed 6 months ago

Commit 14687972 parents f8e48b1 1e26fe22 files · +42 −22Browse files

Unified · Split

2 files
  1. packages/plugins/workos-vault/src/sdk/client.ts +20 −15
  2. packages/plugins/workos-vault/src/sdk/secret-store.ts +22 −7
packages/plugins/workos-vault/src/sdk/client.ts+20−15
@@ -4,7 +4,7 @@ import { · 3 unmodified lines
4 NotFoundException,4 NotFoundException,
5 WorkOS as WorkOSClient,5 WorkOS as WorkOSClient,
6} from "@workos-inc/node/worker";6} from "@workos-inc/node/worker";
7import { Data, Effect, Result } from "effect";7import { Data, Effect, Option, Result, Schema } from "effect";
8 8
9export interface WorkOSVaultObjectMetadata {9export interface WorkOSVaultObjectMetadata {
10 readonly context: Record<string, unknown>;10 readonly context: Record<string, unknown>;
@@ -20,44 +20,49 @@ export interface WorkOSVaultObject { · 9 unmodified lines
20 readonly value?: string;20 readonly value?: string;
21}21}
22 22
23// Minimal shape carrying an HTTP-style status code. Production WorkOS errors23const WORKOS_KEK_NOT_READY_MESSAGE =
24// (`GenericServerException`/`NotFoundException`) and test fakes both populate24 "KEK was created but is not yet ready. This request can be retried.";
25// a numeric `status`, so the boundary normalises against this named type
26// rather than probing arbitrary unknown shapes.
27interface ErrorWithStatus extends Error {
28 readonly status: number;
29}
30 25
31const isErrorWithStatus = (cause: unknown): cause is ErrorWithStatus =>26const CauseWithStatusSchema = Schema.Struct({
32 cause instanceof Error && typeof (cause as ErrorWithStatus).status === "number";27 status: Schema.Number,
28});
33 29
34const statusFromWorkOSCause = (cause: unknown): number | undefined => {30const statusFromWorkOSCause = (cause: unknown): number | undefined => {
35 if (cause instanceof GenericServerException || cause instanceof NotFoundException) {31 if (cause instanceof GenericServerException || cause instanceof NotFoundException) {
36 return cause.status;32 return cause.status;
37 }33 }
38 if (isErrorWithStatus(cause)) return cause.status;34 return Option.match(Schema.decodeUnknownOption(CauseWithStatusSchema)(cause), {
39 return undefined;35 onNone: () => undefined,
36 onSome: (decoded) => decoded.status,
37 });
40};38};
41 39
42const messageFromWorkOSCause = (cause: unknown): string =>40const isKekNotReadyWorkOSCause = (cause: unknown): boolean =>
43 cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "";41 cause instanceof GenericServerException &&
42 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: WorkOS only exposes this retryable Vault condition through its SDK exception message
43 cause.message.endsWith(WORKOS_KEK_NOT_READY_MESSAGE);
44 44
45export class WorkOSVaultClientError extends Data.TaggedError("WorkOSVaultClientError")<{45export class WorkOSVaultClientError extends Data.TaggedError("WorkOSVaultClientError")<{
46 readonly cause: unknown;46 readonly cause: unknown;
47 readonly message: string;47 readonly message: string;
48 readonly operation: string;48 readonly operation: string;
49 readonly retryKind?: "kek_not_ready";
49 readonly status?: number;50 readonly status?: number;
50}> {51}> {
51 constructor(options: {52 constructor(options: {
52 readonly cause: unknown;53 readonly cause: unknown;
53 readonly message?: string;54 readonly message?: string;
54 readonly operation: string;55 readonly operation: string;
56 readonly retryKind?: "kek_not_ready";
55 readonly status?: number;57 readonly status?: number;
56 }) {58 }) {
57 super({59 super({
58 cause: options.cause,60 cause: options.cause,
59 message: options.message ?? messageFromWorkOSCause(options.cause),61 message: options.message ?? `WorkOS Vault ${options.operation} failed`,
60 operation: options.operation,62 operation: options.operation,
63 retryKind:
64 options.retryKind ??
65 (isKekNotReadyWorkOSCause(options.cause) ? "kek_not_ready" : undefined),
61 status: options.status ?? statusFromWorkOSCause(options.cause),66 status: options.status ?? statusFromWorkOSCause(options.cause),
62 });67 });
63 }68 }
packages/plugins/workos-vault/src/sdk/secret-store.ts+22−7
@@ -158,7 +158,7 @@ const isStatusError = (error: WorkOSVaultClientError, status: number): boolean = · 157 unmodified lines
158 error.status === status;158 error.status === status;
159 159
160const isKekNotReadyError = (error: WorkOSVaultClientError): boolean =>160const isKekNotReadyError = (error: WorkOSVaultClientError): boolean =>
161 error.message.includes("KEK was created but is not yet ready");161 error.retryKind === "kek_not_ready";
162 162
163// Default context builder. Each semantic piece of a scope id lives in163// Default context builder. Each semantic piece of a scope id lives in
164// its own vault-context key so WorkOS's KEK matcher sees individual164// its own vault-context key so WorkOS's KEK matcher sees individual
@@ -302,9 +302,6 @@ const deleteSecretValue = ( · 137 unmodified lines
302 return true;302 return true;
303 });303 });
304 304
305const formatVaultError = (error: WorkOSVaultClientError): StorageError =>
306 new StorageError({ message: error.message, cause: error.cause });
307
308// ---------------------------------------------------------------------------305// ---------------------------------------------------------------------------
309// makeWorkOSVaultSecretProvider — builds a SecretProvider backed by306// makeWorkOSVaultSecretProvider — builds a SecretProvider backed by
310// WorkOS Vault for values and the plugin's own metadata table for307// WorkOS Vault for values and the plugin's own metadata table for
@@ -343,7 +340,13 @@ export const makeWorkOSVaultSecretProvider = ( · 32 unmodified lines
343 const meta = yield* store.get(id, scope);340 const meta = yield* store.get(id, scope);
344 if (!meta) return null;341 if (!meta) return null;
345 const object = yield* loadSecretObject(client, prefix, scope, id).pipe(342 const object = yield* loadSecretObject(client, prefix, scope, id).pipe(
346 Effect.mapError(formatVaultError),343 Effect.mapError(
344 (error) =>
345 new StorageError({
346 message: "WorkOS Vault secret read failed",
347 cause: error,
348 }),
349 ),
347 );350 );
348 if (!object || !object.value) return null;351 if (!object || !object.value) return null;
349 return object.value;352 return object.value;
@@ -353,7 +356,13 @@ export const makeWorkOSVaultSecretProvider = ( · 3 unmodified lines
353 Effect.gen(function* () {356 Effect.gen(function* () {
354 const existing = yield* store.get(id, scope);357 const existing = yield* store.get(id, scope);
355 yield* upsertSecretValue(client, prefix, scope, id, value, contextForScope).pipe(358 yield* upsertSecretValue(client, prefix, scope, id, value, contextForScope).pipe(
356 Effect.mapError(formatVaultError),359 Effect.mapError(
360 (error) =>
361 new StorageError({
362 message: "WorkOS Vault secret write failed",
363 cause: error,
364 }),
365 ),
357 );366 );
358 yield* store.upsert({367 yield* store.upsert({
359 id,368 id,
@@ -369,7 +378,13 @@ export const makeWorkOSVaultSecretProvider = ( · 9 unmodified lines
369 const meta = yield* store.get(id, scope);378 const meta = yield* store.get(id, scope);
370 if (!meta) return false;379 if (!meta) return false;
371 yield* deleteSecretValue(client, prefix, scope, id).pipe(380 yield* deleteSecretValue(client, prefix, scope, id).pipe(
372 Effect.mapError(formatVaultError),381 Effect.mapError(
382 (error) =>
383 new StorageError({
384 message: "WorkOS Vault secret delete failed",
385 cause: error,
386 }),
387 ),
373 );388 );
374 yield* store.remove(id, scope);389 yield* store.remove(id, scope);
375 return true;390 return true;