History

Report API client decode failures

Rhys Sullivan committed 6 months ago

Commit dfda4fb1 parent 03548713 files · +111 −5Browse files

Unified · Split

3 files
  1. apps/cloud/src/frontend-atom-error-capture.node.test.ts +74 −0
  2. packages/react/src/api/client.tsx +22 −0
  3. packages/react/src/api/error-reporting.tsx +15 −5
apps/cloud/src/frontend-atom-error-capture.node.test.ts+74−0
@@ -0,0 +1,74 @@
1+import React from "react";
2+import { renderToString } from "react-dom/server";
3+import { RegistryProvider, useAtomSet } from "@effect/atom-react";
4+import { describe, expect, it } from "@effect/vitest";
5+import * as Effect from "effect/Effect";
6+import * as Exit from "effect/Exit";
7+import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
8+import { FetchHttpClient } from "effect/unstable/http";
9+import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
10+import * as Schema from "effect/Schema";
11+
12+const Widget = Schema.Struct({ name: Schema.String });
13+
14+const WidgetGroup = HttpApiGroup.make("widgets").add(
15+ HttpApiEndpoint.get("load", "/widgets", {
16+ success: Widget,
17+ }),
18+);
19+
20+const WidgetApi = HttpApi.make("widget-api").add(WidgetGroup);
21+
22+const malformedWidgetFetch = () =>
23+ Promise.resolve(
24+ new Response(JSON.stringify({ name: 123 }), {
25+ status: 200,
26+ headers: { "content-type": "application/json" },
27+ }),
28+ );
29+
30+const runAtomMutation = async (
31+ transformResponse?: (
32+ effect: Effect.Effect<unknown, unknown, unknown>,
33+ ) => Effect.Effect<unknown, unknown, unknown>,
34+) => {
35+ const originalFetch = globalThis.fetch;
36+ globalThis.fetch = malformedWidgetFetch as typeof fetch;
37+ const WidgetClient = AtomHttpApi.Service<"WidgetClient">()("WidgetClient", {
38+ api: WidgetApi,
39+ baseUrl: "https://executor.test",
40+ httpClient: FetchHttpClient.layer,
41+ ...(transformResponse ? { transformResponse } : {}),
42+ });
43+ const loadWidget = WidgetClient.mutation("widgets", "load");
44+ let runLoad: ((input: {}) => Promise<Exit.Exit<unknown, unknown>>) | undefined;
45+
46+ function CaptureMutation() {
47+ runLoad = useAtomSet(loadWidget, { mode: "promiseExit" });
48+ return null;
49+ }
50+
51+ renderToString(React.createElement(RegistryProvider, null, React.createElement(CaptureMutation)));
52+
53+ const exit = await runLoad!({});
54+ globalThis.fetch = originalFetch;
55+ return exit;
56+};
57+
58+describe("AtomHttpApi frontend decode failures", () => {
59+ it("returns malformed success responses as failed exits in promiseExit mode", async () => {
60+ const exit = await runAtomMutation();
61+
62+ expect(Exit.isFailure(exit)).toBe(true);
63+ });
64+
65+ it("can report malformed success responses once through transformResponse", async () => {
66+ const captured: unknown[] = [];
67+ const exit = await runAtomMutation((effect) =>
68+ Effect.tapCause(effect, (cause) => Effect.sync(() => captured.push(cause))),
69+ );
70+
71+ expect(Exit.isFailure(exit)).toBe(true);
72+ expect(captured).toHaveLength(1);
73+ });
74+});
packages/react/src/api/client.tsx+22−0
@@ -1,8 +1,29 @@
11 import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
22 import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3+import * as HttpClientError from "effect/unstable/http/HttpClientError";
34 import { ExecutorApi } from "@executor-js/api";
5+import * as Cause from "effect/Cause";
6+import * as Effect from "effect/Effect";
7+import * as Option from "effect/Option";
8+import * as Schema from "effect/Schema";
49
510 import { getBaseUrl } from "./base-url";
11+import { reportHandledFrontendError } from "./error-reporting";
12+
13+const isApiClientInfrastructureCause = (cause: Cause.Cause<unknown>): boolean =>
14+ Option.match(Cause.findErrorOption(cause), {
15+ onNone: () => false,
16+ onSome: (error) => Schema.isSchemaError(error) || HttpClientError.isHttpClientError(error),
17+ });
18+
19+const reportApiClientInfrastructureCause = (cause: Cause.Cause<unknown>) =>
20+ Effect.sync(() => {
21+ if (!isApiClientInfrastructureCause(cause)) return;
22+ reportHandledFrontendError(cause, {
23+ surface: "api_client",
24+ action: "decode_or_transport",
25+ });
26+ });
627
728 // ---------------------------------------------------------------------------
829 // Core API client — tools + secrets
@@ -14,6 +35,7 @@ const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorAp · 5 unmodified lines
1435 transformClient: HttpClient.mapRequest((request) =>
1536 HttpClientRequest.prependUrl(request, getBaseUrl()),
1637 ),
38+ transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
1739 });
1840
1941 export { ExecutorApiClient };
packages/react/src/api/error-reporting.tsx+15−5
@@ -32,13 +32,23 @@ const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>( · 31 unmodified lines
3232 defaultFrontendErrorReporter,
3333 );
3434
35+let currentFrontendErrorReporter = defaultFrontendErrorReporter;
36+
37+export const reportHandledFrontendError = (error: unknown, context: FrontendErrorContext): void => {
38+ currentFrontendErrorReporter(error, context);
39+};
40+
3541 export const FrontendErrorReporterProvider = (
3642 props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>,
37−) => (
38− <FrontendErrorReporterContext.Provider value={props.reporter ?? defaultFrontendErrorReporter}>
39− {props.children}
40− </FrontendErrorReporterContext.Provider>
41−);
43+) => {
44+ const reporter = props.reporter ?? defaultFrontendErrorReporter;
45+ currentFrontendErrorReporter = reporter;
46+ return (
47+ <FrontendErrorReporterContext.Provider value={reporter}>
48+ {props.children}
49+ </FrontendErrorReporterContext.Provider>
50+ );
51+};
4252
4353 export const useReportHandledError = (): FrontendErrorReporter =>
4454 React.useContext(FrontendErrorReporterContext);