Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 5 additions & 0 deletions .changeset/fetch-error-wrapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hey-api/openapi-ts": minor
---

Add opt-in typed `FetchError<TError>` wrappers for generated Fetch clients with HTTP metadata and TanStack Query error types.
37 changes: 37 additions & 0 deletions docs/openapi-ts/clients/fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,43 @@ const response = await getFoo({
});
```

## Error Handling

By default, the Fetch client returns errors in the response object. The `error` field contains the typed backend error body and the same result includes HTTP metadata.

```ts
const result = await getFoo();

if (result.error) {
console.log(result.error);
console.log(result.response?.status);
console.log(result.response);
}
```

When `throwOnError` is enabled, the client throws the typed backend error body by default. This preserves legacy behavior for existing applications.

You can opt into a `FetchError<TError>` wrapper with `throwOnErrorStyle: 'wrapper'`. The `.error` field contains the typed backend error body, and the wrapper exposes `.request`, `.response`, `.status`, `.statusText`, and `.headers` for retry, auth, and reporting logic.

```ts
import { FetchError } from './client/client';

try {
await getFoo({
throwOnError: true,
throwOnErrorStyle: 'wrapper',
});
} catch (error) {
if (error instanceof FetchError) {
console.log(error.error);
console.log(error.status);
console.log(error.headers?.get('X-Request-Id'));
}
}
```

Network errors, aborted requests, and interceptor-thrown errors are also wrapped when `throwOnErrorStyle: 'wrapper'` is enabled. They may not have `.response`, `.status`, or `.headers` because no HTTP response was received.

## Interceptors

Interceptors (middleware) can be used to modify requests before they're sent or responses before they're returned to your application.
Expand Down
33 changes: 33 additions & 0 deletions docs/openapi-ts/plugins/tanstack-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,39 @@ export default {

:::

When you use `@hey-api/client-fetch`, generated query and mutation errors are typed as the operation-specific backend error body by default. You can opt into `FetchError<OperationError>` by setting `throwOnErrorStyle: 'wrapper'` on the Fetch client plugin. The `.error` field contains the operation-specific backend error body, and the wrapper exposes Fetch metadata such as `.request`, `.response`, `.status`, `.statusText`, and `.headers`.

```js
export default {
input: 'hey-api/backend',
output: 'src/client',
plugins: [
{
name: '@hey-api/client-fetch',
throwOnErrorStyle: 'wrapper',
},
'@tanstack/react-query',
],
};
```

```ts
import { FetchError } from './client/client';

const addPet = useMutation({
...addPetMutation(),
onError: (error) => {
if (error instanceof FetchError) {
console.log(error.error);
console.log(error.status);
console.log(error.response);
}
},
});
```

Network errors and aborted requests are also wrapped by the Fetch client when `throwOnErrorStyle: 'wrapper'` is enabled. In those cases, `.response`, `.status`, and `.headers` may be `undefined`.

You can customize the naming and casing pattern for `mutationOptions` functions using the `.name` and `.case` options.

### Meta
Expand Down
11 changes: 11 additions & 0 deletions examples/openapi-ts-fastify/src/client/client/client.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
buildUrl,
createConfig,
createInterceptors,
FetchError,
getParseAs,
mergeConfigs,
mergeHeaders,
Expand Down Expand Up @@ -76,6 +77,7 @@ export const createClient = (config: Config = {}): Client => {

const request: Client['request'] = async (options) => {
const throwOnError = options.throwOnError ?? _config.throwOnError;
const throwOnErrorStyle = options.throwOnErrorStyle ?? _config.throwOnErrorStyle;
const responseStyle = options.responseStyle ?? _config.responseStyle;

let request: Request | undefined;
Expand Down Expand Up @@ -211,6 +213,15 @@ export const createClient = (config: Config = {}): Client => {
finalError = finalError || {};

if (throwOnError) {
if (throwOnErrorStyle === 'wrapper') {
throw finalError instanceof FetchError
? finalError
: new FetchError({
error: finalError,
request,
response,
});
}
throw finalError;
}

Expand Down
2 changes: 1 addition & 1 deletion examples/openapi-ts-fastify/src/client/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ export type {
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';
export { createConfig, FetchError, mergeHeaders } from './utils.gen';
8 changes: 8 additions & 0 deletions examples/openapi-ts-fastify/src/client/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export interface Config<T extends ClientOptions = ClientOptions>
* @default false
*/
throwOnError?: T['throwOnError'];
/**
* Error shape to throw when `throwOnError` is enabled. By default, the
* parsed backend error body is thrown to preserve legacy behavior.
*
* @default 'body'
*/
throwOnErrorStyle?: T['throwOnErrorStyle'];
}

export interface RequestOptions<
Expand Down Expand Up @@ -135,6 +142,7 @@ export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
throwOnErrorStyle?: 'body' | 'wrapper';
}

type MethodFn = <
Expand Down
49 changes: 49 additions & 0 deletions examples/openapi-ts-fastify/src/client/client/utils.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,55 @@ import {
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';

const getFetchErrorMessage = ({
error,
response,
}: {
error: unknown;
response?: Response;
}): string => {
if (response) {
const status = response.statusText
? `${response.status} ${response.statusText}`
: `${response.status}`;
return `Response returned ${status}`;
}

if (error instanceof Error && error.message) {
return error.message;
}

return 'Request failed';
};

export class FetchError<TError = unknown> extends Error {
error: TError;
headers?: Headers;
request?: Request;
response?: Response;
status?: number;
statusText?: string;

constructor({
error,
request,
response,
}: {
error: TError;
request?: Request;
response?: Response;
}) {
super(getFetchErrorMessage({ error, response }));
this.name = 'FetchError';
this.error = error;
this.request = request;
this.response = response;
this.status = response?.status;
this.statusText = response?.statusText;
this.headers = response?.headers;
}
}

export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
Expand Down
11 changes: 11 additions & 0 deletions examples/openapi-ts-fetch/src/client/client/client.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
buildUrl,
createConfig,
createInterceptors,
FetchError,
getParseAs,
mergeConfigs,
mergeHeaders,
Expand Down Expand Up @@ -76,6 +77,7 @@ export const createClient = (config: Config = {}): Client => {

const request: Client['request'] = async (options) => {
const throwOnError = options.throwOnError ?? _config.throwOnError;
const throwOnErrorStyle = options.throwOnErrorStyle ?? _config.throwOnErrorStyle;
const responseStyle = options.responseStyle ?? _config.responseStyle;

let request: Request | undefined;
Expand Down Expand Up @@ -211,6 +213,15 @@ export const createClient = (config: Config = {}): Client => {
finalError = finalError || {};

if (throwOnError) {
if (throwOnErrorStyle === 'wrapper') {
throw finalError instanceof FetchError
? finalError
: new FetchError({
error: finalError,
request,
response,
});
}
throw finalError;
}

Expand Down
2 changes: 1 addition & 1 deletion examples/openapi-ts-fetch/src/client/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ export type {
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';
export { createConfig, FetchError, mergeHeaders } from './utils.gen';
8 changes: 8 additions & 0 deletions examples/openapi-ts-fetch/src/client/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export interface Config<T extends ClientOptions = ClientOptions>
* @default false
*/
throwOnError?: T['throwOnError'];
/**
* Error shape to throw when `throwOnError` is enabled. By default, the
* parsed backend error body is thrown to preserve legacy behavior.
*
* @default 'body'
*/
throwOnErrorStyle?: T['throwOnErrorStyle'];
}

export interface RequestOptions<
Expand Down Expand Up @@ -135,6 +142,7 @@ export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
throwOnErrorStyle?: 'body' | 'wrapper';
}

type MethodFn = <
Expand Down
49 changes: 49 additions & 0 deletions examples/openapi-ts-fetch/src/client/client/utils.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,55 @@ import {
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';

const getFetchErrorMessage = ({
error,
response,
}: {
error: unknown;
response?: Response;
}): string => {
if (response) {
const status = response.statusText
? `${response.status} ${response.statusText}`
: `${response.status}`;
return `Response returned ${status}`;
}

if (error instanceof Error && error.message) {
return error.message;
}

return 'Request failed';
};

export class FetchError<TError = unknown> extends Error {
error: TError;
headers?: Headers;
request?: Request;
response?: Response;
status?: number;
statusText?: string;

constructor({
error,
request,
response,
}: {
error: TError;
request?: Request;
response?: Response;
}) {
super(getFetchErrorMessage({ error, response }));
this.name = 'FetchError';
this.error = error;
this.request = request;
this.response = response;
this.status = response?.status;
this.statusText = response?.statusText;
this.headers = response?.headers;
}
}

export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
Expand Down
11 changes: 11 additions & 0 deletions examples/openapi-ts-nestjs/src/client/client/client.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
buildUrl,
createConfig,
createInterceptors,
FetchError,
getParseAs,
mergeConfigs,
mergeHeaders,
Expand Down Expand Up @@ -76,6 +77,7 @@ export const createClient = (config: Config = {}): Client => {

const request: Client['request'] = async (options) => {
const throwOnError = options.throwOnError ?? _config.throwOnError;
const throwOnErrorStyle = options.throwOnErrorStyle ?? _config.throwOnErrorStyle;
const responseStyle = options.responseStyle ?? _config.responseStyle;

let request: Request | undefined;
Expand Down Expand Up @@ -211,6 +213,15 @@ export const createClient = (config: Config = {}): Client => {
finalError = finalError || {};

if (throwOnError) {
if (throwOnErrorStyle === 'wrapper') {
throw finalError instanceof FetchError
? finalError
: new FetchError({
error: finalError,
request,
response,
});
}
throw finalError;
}

Expand Down
2 changes: 1 addition & 1 deletion examples/openapi-ts-nestjs/src/client/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ export type {
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';
export { createConfig, FetchError, mergeHeaders } from './utils.gen';
8 changes: 8 additions & 0 deletions examples/openapi-ts-nestjs/src/client/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export interface Config<T extends ClientOptions = ClientOptions>
* @default false
*/
throwOnError?: T['throwOnError'];
/**
* Error shape to throw when `throwOnError` is enabled. By default, the
* parsed backend error body is thrown to preserve legacy behavior.
*
* @default 'body'
*/
throwOnErrorStyle?: T['throwOnErrorStyle'];
}

export interface RequestOptions<
Expand Down Expand Up @@ -135,6 +142,7 @@ export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
throwOnErrorStyle?: 'body' | 'wrapper';
}

type MethodFn = <
Expand Down
Loading
Loading