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..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,15 +261,82 @@ 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 getOperationErrorMatcher( + errors: readonly ApiErrorClass[] | undefined, +): OperationErrorMatcher { + if (!errors || errors.length === 0) return noOperationErrorMatcher; + + const cached = operationErrorMatcherCache.get(errors); + if (cached) return cached; + + const errorSchemas: OperationErrorSchema[] = []; + for (const errorSchema of errors) { + 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 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, + }; + 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; +} + /** * Match a Cloudflare API error response using per-operation error schemas. */ -const matchError = ( +export const matchCloudflareError = ( status: number, errorBody: unknown, 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" && @@ -272,6 +344,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(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 +394,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(undefined, status, bodyStr); + if (matched) return matched; + if (status >= 500) { return Effect.fail(httpStatusError(status, bodyStr, headers)); } @@ -329,51 +413,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(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 +503,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();