Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/cloudflare/patches/turnstile/deleteWidget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"errors": {
"NotFound": [{ "status": 404 }]
}
}
5 changes: 5 additions & 0 deletions packages/cloudflare/patches/turnstile/getWidget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"errors": {
"NotFound": [{ "status": 404 }]
}
}
2 changes: 1 addition & 1 deletion packages/cloudflare/scripts/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ interface OperationPatch {
errors: Record<
string,
Array<{
code: number;
code?: number;
status?: number;
message?: { includes?: string; matches?: string };
}>
Expand Down
6 changes: 6 additions & 0 deletions packages/cloudflare/specs/cloudflare/turnstile.openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
112 changes: 65 additions & 47 deletions packages/cloudflare/src/client/api.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we making changes to this file?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change is needed because Turnstile returns bare/non-envelope 404 responses for missing widgets. the generated operation patch can now declare { status: 404 }, but api.ts has to try operation-specific matchers before falling back to generic CloudflareHttpError for those non-envelope responses. added comments and regression tests for both non-json and non-envelope cases.

Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,59 @@ 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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, why is this necessary?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems to be to handle when there is no envelope from cloudflare; I found it weird when i first looked at this, but it seems fine

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a // comment above matchOperationError explaining the Cloudflare no-envelope case. The generated Turnstile operation patch declares the 404 matcher, but api.ts has to try operation-specific matchers before falling back to CloudflareHttpError when Cloudflare returns a bare HTTP failure. Re-ran bun --filter @distilled.cloud/cloudflare check and bun run vitest run test/client-api.test.ts from packages/cloudflare.

errors: readonly ApiErrorClass[] | undefined,
code: number | undefined,
status: number,
message: string,
): Effect.Effect<never, unknown> | undefined {
if (!errors || errors.length === 0) return undefined;

const errorSchemas = new Map<string, Schema.Top>();
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);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't do all this work on every request. Instead, it should create this map and return a function for matching, that way we cache the map in the closure once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed, lmk what you think


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<never, unknown>;
}

/**
* Match a Cloudflare API error response using per-operation error schemas.
*/
const matchError = (
export const matchCloudflareError = (
status: number,
errorBody: unknown,
errors?: readonly ApiErrorClass[],
Expand All @@ -272,6 +321,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));
Expand Down Expand Up @@ -316,6 +371,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));
}
Expand All @@ -329,51 +390,8 @@ const matchError = (
);
}

// Build error schema map from the per-operation errors
if (errors && errors.length > 0) {
const errorSchemas = new Map<string, Schema.Top>();
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<never, unknown>;
}
}
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) {
Expand Down Expand Up @@ -462,7 +480,7 @@ const _API = makeAPI<Credentials>({
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 }),
Expand Down
18 changes: 14 additions & 4 deletions packages/cloudflare/src/services/turnstile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()("NotFound", {
code: Schema.Number,
message: Schema.String,
}) {}
T.applyErrorMatchers(NotFound, [{ status: 404 }]);
Comment on lines +16 to +23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are generated files do not modify them directly. add a patch and then rerun generate

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed: moved this into packages/cloudflare/patches/turnstile/{getWidget,deleteWidget}.json and reran bun scripts/generate.ts --service turnstile, so turnstile.ts is generated again.


// =============================================================================
// SecretWidget
// =============================================================================
Expand Down Expand Up @@ -201,7 +211,7 @@ export const GetWidgetResponse = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({
T.ResponsePath("result"),
) as unknown as Schema.Schema<GetWidgetResponse>;

export type GetWidgetError = DefaultErrors;
export type GetWidgetError = DefaultErrors | NotFound;

export const getWidget: API.OperationMethod<
GetWidgetRequest,
Expand All @@ -211,7 +221,7 @@ export const getWidget: API.OperationMethod<
> = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({
input: GetWidgetRequest,
output: GetWidgetResponse,
errors: [],
errors: [NotFound],
}));

export interface ListWidgetsRequest {
Expand Down Expand Up @@ -690,7 +700,7 @@ export const DeleteWidgetResponse = /*@__PURE__*/ /*#__PURE__*/ Schema.Struct({
T.ResponsePath("result"),
) as unknown as Schema.Schema<DeleteWidgetResponse>;

export type DeleteWidgetError = DefaultErrors;
export type DeleteWidgetError = DefaultErrors | NotFound;

export const deleteWidget: API.OperationMethod<
DeleteWidgetRequest,
Expand All @@ -700,5 +710,5 @@ export const deleteWidget: API.OperationMethod<
> = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({
input: DeleteWidgetRequest,
output: DeleteWidgetResponse,
errors: [],
errors: [NotFound],
}));
27 changes: 26 additions & 1 deletion packages/cloudflare/test/client-api.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading