Skip to content

HttpApiClient: map every method's errors to one type, knowing which endpoint failed #9907

Open

Description

@fubhy

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, where Client.Error<Groups> is the union of every method's errors. Every method, and every stream a method returns (also inside WithHeaders), fails with E2. Defects stay defects.
  • transformResponse gets the same info as a second argument. It stays untyped, for spans and similar wrapping.
  • make takes the same options.

Related

Open questions

  • Should mapError also see responseMode: "response-only" calls, which have no decoded errors?
  • Is info enough with group and endpoint identifiers, or should it carry the HttpApiEndpoint itself, so a mapper can read annotations?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions