Unified · Split
| @@ -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 | }); | ||
| @@ -1,8 +1,29 @@ | |||
| 1 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; | 1 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; |
| 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 3 | import * as HttpClientError from "effect/unstable/http/HttpClientError"; | ||
| 3 | import { ExecutorApi } from "@executor-js/api"; | 4 | 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"; | ||
| 4 | 9 | ||
| 5 | import { getBaseUrl } from "./base-url"; | 10 | 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 | }); | ||
| 6 | 27 | ||
| 7 | // --------------------------------------------------------------------------- | 28 | // --------------------------------------------------------------------------- |
| 8 | // Core API client — tools + secrets | 29 | // 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 | ||
| 19 | export { ExecutorApiClient }; | 41 | export { ExecutorApiClient }; |
| @@ -32,13 +32,23 @@ const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>( · 31 unmodified lines | |||
| 32 | defaultFrontendErrorReporter, | 32 | defaultFrontendErrorReporter, |
| 33 | ); | 33 | ); |
| 34 | 34 | ||
| 35 | let currentFrontendErrorReporter = defaultFrontendErrorReporter; | ||
| 36 | |||
| 37 | export const reportHandledFrontendError = (error: unknown, context: FrontendErrorContext): void => { | ||
| 38 | currentFrontendErrorReporter(error, context); | ||
| 39 | }; | ||
| 40 | |||
| 35 | export const FrontendErrorReporterProvider = ( | 41 | export 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 | ||
| 43 | export const useReportHandledError = (): FrontendErrorReporter => | 53 | export const useReportHandledError = (): FrontendErrorReporter => |
| 44 | React.useContext(FrontendErrorReporterContext); | 54 | React.useContext(FrontendErrorReporterContext); |