From 0621269b173f8cb8b72b124ddb307bbdb29e94d2 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 20 May 2026 11:07:53 +0200 Subject: [PATCH 1/3] fix(cloudflare): tag turnstile missing widgets as not found --- .../patches/turnstile/deleteWidget.json | 5 + .../patches/turnstile/getWidget.json | 5 + packages/cloudflare/scripts/generate.ts | 2 +- .../specs/cloudflare/turnstile.openapi.yml | 6 + packages/cloudflare/src/client/api.ts | 108 ++++++++++-------- packages/cloudflare/src/services/turnstile.ts | 18 ++- packages/cloudflare/test/client-api.test.ts | 27 ++++- 7 files changed, 118 insertions(+), 53 deletions(-) create mode 100644 packages/cloudflare/patches/turnstile/deleteWidget.json create mode 100644 packages/cloudflare/patches/turnstile/getWidget.json diff --git a/packages/cloudflare/patches/turnstile/deleteWidget.json b/packages/cloudflare/patches/turnstile/deleteWidget.json new file mode 100644 index 000000000..b549b7f80 --- /dev/null +++ b/packages/cloudflare/patches/turnstile/deleteWidget.json @@ -0,0 +1,5 @@ +{ + "errors": { + "NotFound": [{ "status": 404 }] + } +} diff --git a/packages/cloudflare/patches/turnstile/getWidget.json b/packages/cloudflare/patches/turnstile/getWidget.json new file mode 100644 index 000000000..b549b7f80 --- /dev/null +++ b/packages/cloudflare/patches/turnstile/getWidget.json @@ -0,0 +1,5 @@ +{ + "errors": { + "NotFound": [{ "status": 404 }] + } +} diff --git a/packages/cloudflare/scripts/generate.ts b/packages/cloudflare/scripts/generate.ts index d0cb14d38..acba91408 100644 --- a/packages/cloudflare/scripts/generate.ts +++ b/packages/cloudflare/scripts/generate.ts @@ -180,7 +180,7 @@ interface OperationPatch { errors: Record< string, Array<{ - code: number; + code?: number; status?: number; message?: { includes?: string; matches?: string }; }> diff --git a/packages/cloudflare/specs/cloudflare/turnstile.openapi.yml b/packages/cloudflare/specs/cloudflare/turnstile.openapi.yml index 936d3feeb..0b03dc429 100644 --- a/packages/cloudflare/specs/cloudflare/turnstile.openapi.yml +++ b/packages/cloudflare/specs/cloudflare/turnstile.openapi.yml @@ -215,6 +215,9 @@ paths: - secret - sitekey x-distilled-response-path: result + x-distilled-errors: + NotFound: + - status: 404 put: operationId: updateWidget description: "Update the configuration of a widget. @example ```ts const widget @@ -464,6 +467,9 @@ paths: - secret - sitekey x-distilled-response-path: result + x-distilled-errors: + NotFound: + - status: 404 /accounts/{account_id}/challenges/widgets: get: operationId: listWidgets diff --git a/packages/cloudflare/src/client/api.ts b/packages/cloudflare/src/client/api.ts index 3c22dc8ea..978ffded2 100644 --- a/packages/cloudflare/src/client/api.ts +++ b/packages/cloudflare/src/client/api.ts @@ -256,10 +256,55 @@ function findMatchingError( return bestMatch; } +function matchOperationError( + errors: readonly ApiErrorClass[] | undefined, + code: number | undefined, + status: number, + message: string, +): Effect.Effect | undefined { + if (!errors || errors.length === 0) return undefined; + + const errorSchemas = new Map(); + for (const errorSchema of errors) { + const identifier = extractTagFromAst( + (errorSchema as unknown as Schema.Top).ast, + ); + if (identifier) { + errorSchemas.set(identifier, errorSchema as unknown as Schema.Top); + } + } + + const matched = findMatchingError(errorSchemas, code, status, message); + if (!matched) return undefined; + + const errorData = { + _tag: matched.tag, + code: code ?? 0, + message, + }; + return Schema.decodeUnknownEffect(matched.schema)(errorData).pipe( + Effect.flatMap((decoded: unknown) => Effect.fail(decoded)), + Effect.catchIf( + (e: unknown) => + typeof e === "object" && + e !== null && + "_tag" in e && + (e as any)._tag === "SchemaError", + () => + Effect.fail( + new UnknownCloudflareError({ + code, + message, + }), + ), + ), + ) as Effect.Effect; +} + /** * Match a Cloudflare API error response using per-operation error schemas. */ -const matchError = ( +export const matchCloudflareError = ( status: number, errorBody: unknown, errors?: readonly ApiErrorClass[], @@ -272,6 +317,12 @@ const matchError = ( "_nonJsonError" in errorBody; if (isNonJsonError) { const message = String((errorBody as any).body); + // Some Cloudflare endpoints return bare HTTP errors without the standard + // `{ success: false, errors: [...] }` envelope. Give operation-specific + // status matchers a chance before falling back to generic HTTP errors. + const matched = matchOperationError(errors, undefined, status, message); + if (matched) return matched; + // For 5xx errors, return a properly categorized error so retries work if (status >= 500) { return Effect.fail(httpStatusError(status, message, headers)); @@ -316,6 +367,12 @@ const matchError = ( // For 5xx errors, return a properly categorized error so retries work const bodyStr = typeof errorBody === "string" ? errorBody : JSON.stringify(errorBody); + // Preserve per-operation error typing for endpoints whose error responses + // are JSON but not Cloudflare envelopes, such as Turnstile's missing-widget + // 404 response. + const matched = matchOperationError(errors, undefined, status, bodyStr); + if (matched) return matched; + if (status >= 500) { return Effect.fail(httpStatusError(status, bodyStr, headers)); } @@ -329,51 +386,8 @@ const matchError = ( ); } - // Build error schema map from the per-operation errors - if (errors && errors.length > 0) { - const errorSchemas = new Map(); - for (const errorSchema of errors) { - const identifier = extractTagFromAst( - (errorSchema as unknown as Schema.Top).ast, - ); - if (identifier) { - errorSchemas.set(identifier, errorSchema as unknown as Schema.Top); - } - } - - const matched = findMatchingError( - errorSchemas, - errorCode, - status, - errorMessage, - ); - - if (matched) { - // Decode using the schema - properly instantiates TaggedError classes - const errorData = { - _tag: matched.tag, - code: errorCode ?? 0, - message: errorMessage, - }; - return Schema.decodeUnknownEffect(matched.schema)(errorData).pipe( - Effect.flatMap((decoded: unknown) => Effect.fail(decoded)), - Effect.catchIf( - (e: unknown) => - typeof e === "object" && - e !== null && - "_tag" in e && - (e as any)._tag === "SchemaError", - () => - Effect.fail( - new UnknownCloudflareError({ - code: errorCode, - message: errorMessage, - }), - ), - ), - ) as Effect.Effect; - } - } + const matched = matchOperationError(errors, errorCode, status, errorMessage); + if (matched) return matched; // Check global error codes before falling through to unknown if (errorCode !== undefined && errorCode in GLOBAL_ERROR_CODE_MAP) { @@ -462,7 +476,7 @@ const _API = makeAPI({ credentials: Credentials as any, getBaseUrl: (creds: any) => creds.apiBaseUrl, getAuthHeaders: formatHeaders as any, - matchError, + matchError: matchCloudflareError, ParseError: CloudflareDecodeError as any, transformRequestParts: ({ pathTemplate, parts }) => transformCloudflareRequestParts({ pathTemplate, parts }), diff --git a/packages/cloudflare/src/services/turnstile.ts b/packages/cloudflare/src/services/turnstile.ts index 1892b048c..cb80702fa 100644 --- a/packages/cloudflare/src/services/turnstile.ts +++ b/packages/cloudflare/src/services/turnstile.ts @@ -12,6 +12,16 @@ import * as T from "../traits.ts"; import type { Credentials } from "../credentials.ts"; import { type DefaultErrors } from "../errors.ts"; +// ============================================================================= +// Errors +// ============================================================================= + +export class NotFound extends Schema.TaggedErrorClass()("NotFound", { + code: Schema.Number, + message: Schema.String, +}) {} +T.applyErrorMatchers(NotFound, [{ status: 404 }]); + // ============================================================================= // SecretWidget // ============================================================================= @@ -201,7 +211,7 @@ export const GetWidgetResponse = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ T.ResponsePath("result"), ) as unknown as Schema.Schema; -export type GetWidgetError = DefaultErrors; +export type GetWidgetError = DefaultErrors | NotFound; export const getWidget: API.OperationMethod< GetWidgetRequest, @@ -211,7 +221,7 @@ export const getWidget: API.OperationMethod< > = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ input: GetWidgetRequest, output: GetWidgetResponse, - errors: [], + errors: [NotFound], })); export interface ListWidgetsRequest { @@ -690,7 +700,7 @@ export const DeleteWidgetResponse = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({ T.ResponsePath("result"), ) as unknown as Schema.Schema; -export type DeleteWidgetError = DefaultErrors; +export type DeleteWidgetError = DefaultErrors | NotFound; export const deleteWidget: API.OperationMethod< DeleteWidgetRequest, @@ -700,5 +710,5 @@ export const deleteWidget: API.OperationMethod< > = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({ input: DeleteWidgetRequest, output: DeleteWidgetResponse, - errors: [], + errors: [NotFound], })); diff --git a/packages/cloudflare/test/client-api.test.ts b/packages/cloudflare/test/client-api.test.ts index 461840077..ece915e41 100644 --- a/packages/cloudflare/test/client-api.test.ts +++ b/packages/cloudflare/test/client-api.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "vitest"; import { buildRequestParts, getHttpTrait } from "@distilled.cloud/core/traits"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { transformCloudflareRequestParts } from "~/client/api"; +import { + matchCloudflareError, + transformCloudflareRequestParts, +} from "~/client/api"; import { CreateAssetUploadRequest, PutScriptRequest } from "~/services/workers"; +import { NotFound as TurnstileNotFound } from "~/services/turnstile"; import { GetPhasResponse, PutPhasForAccountRequest, @@ -60,6 +65,26 @@ describe("client api", () => { }); }); + it("matches operation errors on non-json Cloudflare error responses", () => + matchCloudflareError( + 404, + { _nonJsonError: true, body: "widget not found" }, + [TurnstileNotFound], + ).pipe( + Effect.flip, + Effect.map((error) => expect(error._tag).toBe("NotFound")), + Effect.runPromise, + )); + + it("matches operation errors on non-envelope Cloudflare error responses", () => + matchCloudflareError(404, { error: "widget not found" }, [ + TurnstileNotFound, + ]).pipe( + Effect.flip, + Effect.map((error) => expect(error._tag).toBe("NotFound")), + Effect.runPromise, + )); + it("encodes Worker script uploads with ratelimit bindings", () => { const httpTrait = getHttpTrait(PutScriptRequest.ast); expect(httpTrait).toBeDefined(); From 6750db733a6f2a3ee06a2e934024bd6d6707f64d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 22 May 2026 09:30:16 +0200 Subject: [PATCH 2/3] docs(cloudflare): explain operation error matching --- packages/cloudflare/src/client/api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cloudflare/src/client/api.ts b/packages/cloudflare/src/client/api.ts index 978ffded2..2501aff1a 100644 --- a/packages/cloudflare/src/client/api.ts +++ b/packages/cloudflare/src/client/api.ts @@ -256,6 +256,10 @@ function findMatchingError( return bestMatch; } +// Some Cloudflare endpoints return bare HTTP failures instead of the standard +// `{ success: false, errors: [...] }` envelope. Generated operation schemas can +// still declare status-based error matchers, so try those before falling back +// to generic HTTP errors. function matchOperationError( errors: readonly ApiErrorClass[] | undefined, code: number | undefined, From 327926d6cdca710effdfc93b8e9667f5e2f84709 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 22 May 2026 19:24:22 +0200 Subject: [PATCH 3/3] perf(cloudflare): cache operation error matchers --- packages/cloudflare/src/client/api.ts | 115 +++++++++++++++----------- 1 file changed, 69 insertions(+), 46 deletions(-) diff --git a/packages/cloudflare/src/client/api.ts b/packages/cloudflare/src/client/api.ts index 2501aff1a..b7bfe8be9 100644 --- a/packages/cloudflare/src/client/api.ts +++ b/packages/cloudflare/src/client/api.ts @@ -220,6 +220,12 @@ function matcherSpecificity(matcher: ErrorMatcher): number { return score; } +interface OperationErrorSchema { + schema: Schema.Top; + tag: string; + matchers: readonly ErrorMatcher[]; +} + interface MatchedError { schema: Schema.Top; tag: string; @@ -229,7 +235,7 @@ interface MatchedError { * Find matching error schema using annotations from the schema AST. */ function findMatchingError( - errorSchemas: Map, + errorSchemas: readonly OperationErrorSchema[], code: number | undefined, status: number, message: string, @@ -237,17 +243,16 @@ function findMatchingError( let bestMatch: MatchedError | undefined; let bestScore = 0; - for (const [name, schema] of errorSchemas) { - const ast = schema.ast; - const matchers = getErrorMatchers(ast); - if (!matchers || matchers.length === 0) continue; - - for (const matcher of matchers) { + for (const errorSchema of errorSchemas) { + for (const matcher of errorSchema.matchers) { if (matchesExpression(matcher, code, status, message)) { const score = matcherSpecificity(matcher); if (score > bestScore) { bestScore = score; - bestMatch = { schema, tag: name }; + bestMatch = { + schema: errorSchema.schema, + tag: errorSchema.tag, + }; } } } @@ -256,53 +261,69 @@ function findMatchingError( return bestMatch; } +type OperationErrorMatcher = ( + code: number | undefined, + status: number, + message: string, +) => Effect.Effect | undefined; + +const noOperationErrorMatcher: OperationErrorMatcher = () => undefined; +const operationErrorMatcherCache = new WeakMap< + readonly ApiErrorClass[], + OperationErrorMatcher +>(); + // Some Cloudflare endpoints return bare HTTP failures instead of the standard // `{ success: false, errors: [...] }` envelope. Generated operation schemas can // still declare status-based error matchers, so try those before falling back // to generic HTTP errors. -function matchOperationError( +function getOperationErrorMatcher( errors: readonly ApiErrorClass[] | undefined, - code: number | undefined, - status: number, - message: string, -): Effect.Effect | undefined { - if (!errors || errors.length === 0) return undefined; +): OperationErrorMatcher { + if (!errors || errors.length === 0) return noOperationErrorMatcher; + + const cached = operationErrorMatcherCache.get(errors); + if (cached) return cached; - const errorSchemas = new Map(); + const errorSchemas: OperationErrorSchema[] = []; for (const errorSchema of errors) { - const identifier = extractTagFromAst( - (errorSchema as unknown as Schema.Top).ast, - ); - if (identifier) { - errorSchemas.set(identifier, errorSchema as unknown as Schema.Top); + const schema = errorSchema as unknown as Schema.Top; + const identifier = extractTagFromAst(schema.ast); + const matchers = getErrorMatchers(schema.ast); + if (identifier && matchers && matchers.length > 0) { + errorSchemas.push({ schema, tag: identifier, matchers }); } } - const matched = findMatchingError(errorSchemas, code, status, message); - if (!matched) return undefined; + const matcher: OperationErrorMatcher = (code, status, message) => { + const matched = findMatchingError(errorSchemas, code, status, message); + if (!matched) return undefined; - const errorData = { - _tag: matched.tag, - code: code ?? 0, - message, + const errorData = { + _tag: matched.tag, + code: code ?? 0, + message, + }; + return Schema.decodeUnknownEffect(matched.schema)(errorData).pipe( + Effect.flatMap((decoded: unknown) => Effect.fail(decoded)), + Effect.catchIf( + (e: unknown) => + typeof e === "object" && + e !== null && + "_tag" in e && + (e as any)._tag === "SchemaError", + () => + Effect.fail( + new UnknownCloudflareError({ + code, + message, + }), + ), + ), + ) as Effect.Effect; }; - return Schema.decodeUnknownEffect(matched.schema)(errorData).pipe( - Effect.flatMap((decoded: unknown) => Effect.fail(decoded)), - Effect.catchIf( - (e: unknown) => - typeof e === "object" && - e !== null && - "_tag" in e && - (e as any)._tag === "SchemaError", - () => - Effect.fail( - new UnknownCloudflareError({ - code, - message, - }), - ), - ), - ) as Effect.Effect; + operationErrorMatcherCache.set(errors, matcher); + return matcher; } /** @@ -314,6 +335,8 @@ export const matchCloudflareError = ( errors?: readonly ApiErrorClass[], headers?: Record, ): Effect.Effect => { + const matchOperationError = getOperationErrorMatcher(errors); + // Handle non-JSON error responses (e.g., HTML from malformed URLs, 520 pages) const isNonJsonError = typeof errorBody === "object" && @@ -324,7 +347,7 @@ export const matchCloudflareError = ( // Some Cloudflare endpoints return bare HTTP errors without the standard // `{ success: false, errors: [...] }` envelope. Give operation-specific // status matchers a chance before falling back to generic HTTP errors. - const matched = matchOperationError(errors, undefined, status, message); + const matched = matchOperationError(undefined, status, message); if (matched) return matched; // For 5xx errors, return a properly categorized error so retries work @@ -374,7 +397,7 @@ export const matchCloudflareError = ( // Preserve per-operation error typing for endpoints whose error responses // are JSON but not Cloudflare envelopes, such as Turnstile's missing-widget // 404 response. - const matched = matchOperationError(errors, undefined, status, bodyStr); + const matched = matchOperationError(undefined, status, bodyStr); if (matched) return matched; if (status >= 500) { @@ -390,7 +413,7 @@ export const matchCloudflareError = ( ); } - const matched = matchOperationError(errors, errorCode, status, errorMessage); + const matched = matchOperationError(errorCode, status, errorMessage); if (matched) return matched; // Check global error codes before falling through to unknown