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 @@
1import React from "react";
2import { renderToString } from "react-dom/server";
3import { RegistryProvider, useAtomSet } from "@effect/atom-react";
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
8import { FetchHttpClient } from "effect/unstable/http";
9import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
10import * as Schema from "effect/Schema";
11
12const Widget = Schema.Struct({ name: Schema.String });
13
14const WidgetGroup = HttpApiGroup.make("widgets").add(
15 HttpApiEndpoint.get("load", "/widgets", {
16 success: Widget,
17 }),
18);
19
20const WidgetApi = HttpApi.make("widget-api").add(WidgetGroup);
21
22const malformedWidgetFetch = () =>
23 Promise.resolve(
24 new Response(JSON.stringify({ name: 123 }), {
25 status: 200,
26 headers: { "content-type": "application/json" },
27 }),
28 );
29
30const 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
58describe("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 @@
1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";2import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3import * as HttpClientError from "effect/unstable/http/HttpClientError";
3import { ExecutorApi } from "@executor-js/api";4import { ExecutorApi } from "@executor-js/api";
5import * as Cause from "effect/Cause";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
4 9
5import { getBaseUrl } from "./base-url";10import { getBaseUrl } from "./base-url";
11import { reportHandledFrontendError } from "./error-reporting";
12
13const 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
19const 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 });
6 27
7// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
8// Core API client — tools + secrets29// Core API client — tools + secrets
@@ -14,6 +35,7 @@ const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorAp · 5 unmodified lines
14 transformClient: HttpClient.mapRequest((request) =>35 transformClient: HttpClient.mapRequest((request) =>
15 HttpClientRequest.prependUrl(request, getBaseUrl()),36 HttpClientRequest.prependUrl(request, getBaseUrl()),
16 ),37 ),
38 transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
17});39});
18 40
19export { ExecutorApiClient };41export { ExecutorApiClient };
packages/react/src/api/error-reporting.tsx+15−5
@@ -32,13 +32,23 @@ const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>( · 31 unmodified lines
32 defaultFrontendErrorReporter,32 defaultFrontendErrorReporter,
33);33);
34 34
35let currentFrontendErrorReporter = defaultFrontendErrorReporter;
36
37export const reportHandledFrontendError = (error: unknown, context: FrontendErrorContext): void => {
38 currentFrontendErrorReporter(error, context);
39};
40
35export const FrontendErrorReporterProvider = (41export const FrontendErrorReporterProvider = (
36 props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>,42 props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>,
37) => (43) => {
38 <FrontendErrorReporterContext.Provider value={props.reporter ?? defaultFrontendErrorReporter}>44 const reporter = props.reporter ?? defaultFrontendErrorReporter;
39 {props.children}45 currentFrontendErrorReporter = reporter;
40 </FrontendErrorReporterContext.Provider>46 return (
41);47 <FrontendErrorReporterContext.Provider value={reporter}>
48 {props.children}
49 </FrontendErrorReporterContext.Provider>
50 );
51};
42 52
43export const useReportHandledError = (): FrontendErrorReporter =>53export const useReportHandledError = (): FrontendErrorReporter =>
44 React.useContext(FrontendErrorReporterContext);54 React.useContext(FrontendErrorReporterContext);