Repository navigation
HttpApiClient: map every method's errors to one type, knowing which endpoint failed #9907
Description
Problem
A client library built on HttpApi often exposes one error type, the way an SDK does. Docker's client fails every operation with a DockerError that holds the reason (the declared error, a connection failure, an invalid response) and names the operation (docker.Containers.inspect).
An HttpApiClient method fails with a different union per endpoint: HttpClientError | SchemaError | the endpoint's declared errors | its middleware errors. A streamed success (Stream<A, E>) carries errors of its own. The only hook, transformResponse, is untyped (Effect<unknown, unknown, unknown> => Effect<unknown, unknown, unknown>, HttpApiClient.ts L648), and isn't told which endpoint it runs for.
So a wrapper re-implements the client's shape:
// a mapped type that re-types every method's error, streams included
type ApiClient<Group> = { readonly [Id in keyof Group["endpoints"]]: ApiMethod<Group["endpoints"][Id]> }
// and at runtime, a walk over group.endpoints that wraps each method
Object.fromEntries(
Object.entries(group.endpoints).map(([id, endpoint]) => [id, (request) =>
client[id](request).pipe(
Effect.map(mapStreamErrors((e) => toApiError(e, `${group.identifier}.${id}`))),
Effect.mapError((e) => toApiError(e, `${group.identifier}.${id}`)),
Effect.withSpan(`${group.identifier}.${id}`)
)]
)
)Typing a per-endpoint function over every method's own error type isn't possible. A mapping whose output is one type for all methods is easy to type, though, and covers this case.
Proposal
A mapError option, with the group and endpoint that failed. Pass the same information to transformResponse:
const client = yield* HttpApiClient.makeWith(Api, {
httpClient,
mapError: (error, { group, endpoint }) => new ApiError({ operation: `${group}.${endpoint}`, cause: error }),
transformResponse: (effect, { group, endpoint }) => Effect.withSpan(effect, `${group}.${endpoint}`)
})
client.containers.inspect({ params: { id } }) // Effect<ContainerInspect, ApiError>
client.containers.statsStream({ params: { id } }) // Stream<Stats, ApiError>mapError: (error: Client.Error<Groups>, info: { readonly group: string; readonly endpoint: string }) => E2, whereClient.Error<Groups>is the union of every method's errors. Every method, and every stream a method returns (also insideWithHeaders), fails withE2. Defects stay defects.transformResponsegets the sameinfoas a second argument. It stays untyped, for spans and similar wrapping.maketakes the same options.
Related
- HttpApiClient reports undeclared HTTP statuses as DecodeError even when the response body is valid #9866: an undeclared status arrives as an
HttpClientErrorwith aDecodeErrorreason, which a mapper has to tell apart from a body that failed to decode. A distinct reason would makemapErrorsimpler to write.
Open questions
- Should
mapErroralso seeresponseMode: "response-only"calls, which have no decoded errors? - Is
infoenough withgroupandendpointidentifiers, or should it carry theHttpApiEndpointitself, so a mapper can read annotations?
Activity