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 | 1 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; |
| 2 | 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 3 | +import * as HttpClientError from "effect/unstable/http/HttpClientError"; | |
| 3 | 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 | 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 | 29 | // Core API client — tools + secrets |
| @@ -14,6 +35,7 @@ const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorAp · 5 unmodified lines | ||
| 14 | 35 | transformClient: HttpClient.mapRequest((request) => |
| 15 | 36 | HttpClientRequest.prependUrl(request, getBaseUrl()), |
| 16 | 37 | ), |
| 38 | + transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), | |
| 17 | 39 | }); |
| 18 | 40 | |
| 19 | 41 | export { ExecutorApiClient }; |
| @@ -32,13 +32,23 @@ const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>( · 31 unmodified lines | ||
| 32 | 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 | 41 | export const FrontendErrorReporterProvider = ( |
| 36 | 42 | 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 | +}; | |
| 42 | 52 | |
| 43 | 53 | export const useReportHandledError = (): FrontendErrorReporter => |
| 44 | 54 | React.useContext(FrontendErrorReporterContext); |