# Retries, timeouts and spans for TS Rest clients

> Customize your TS Rest API calls to enable capabilities such as retries, timeouts and span information

- Canonical: https://dennisokeeffe.com/blog/2025-04-10-retries-spans-timeouts-for-ts-rest
- Published: 2025-04-10
- Updated: 2025-04-10
- Tags: typescript

Working with [TS Rest](https://ts-rest.com/) is great for any workflows where your consumers are TypeScript frontend and backend applications.

If you work extensively with [Zod](https://zod.dev/) and tools like [Tanstack Query](https://tanstack.com/query/latest) (if you are using Vue/React at this point in time), it can be a natural fit and time saver to generate and distribute your clients this way.

In recent work, I've been using TS Rest to pair up my smaller Hono projects with clients, but it becomes immediately apparent to me just how good Tanstack Query can be out of the box with it's out-of-the-box capabilities around retries and caching.

This short blog post aims to look at ways to make use of TS Rest customization in order to have some control over how we can introduce this to our other clients.

## Custom TS Rest clients

TS Rest gives you the capability to create your own [custom client](https://ts-rest.com/docs/core/custom) which can be used for controlling how your HTTP calls are made.

As a simple example:

```ts
import { initClient, tsRestFetchApi } from "@ts-rest/core";
import { contract } from "./contract";

const client = initClient(contract, {
  baseUrl: "http://localhost:5003",
  baseHeaders: {},
  api: async (args) => {
    // Add anything you want here!

    return tsRestFetchApi(args);
  },
});
```

In their example code here, they expose their default `tsRestFetchApi` and allow you to control anything you want based off the args prior to making any calls.

There are also guides on how to create your own custom client with [Axios](https://axios-http.com/docs/intro):

```ts
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from "axios";
import { initClient } from "@ts-rest/core";
import { contract } from "./contract";

const client = initClient(contract, {
  baseUrl: "http://localhost:3333/api",
  baseHeaders: {
    "Content-Type": "application/json",
  },
  api: async ({ path, method, headers, body }) => {
    const baseUrl = "http://localhost:3333/api"; //baseUrl is not available as a param, yet
    try {
      const result = await axios.request({
        method: method as Method,
        url: `${this.baseUrl}/${path}`,
        headers,
        data: body,
      });
      return {
        status: result.status,
        body: result.data,
        headers: result.headers,
      };
    } catch (e: Error | AxiosError | any) {
      if (isAxiosError(e)) {
        const error = e as AxiosError;
        const response = error.response as AxiosResponse;
        return {
          status: response.status,
          body: response.data,
          headers: response.headers,
        };
      }
      throw e;
    }
  },
});
```

In our example code today, we will be creating a custom handler for our `api` property with Axios.

## What can we extend?

We will be doing more "showing" than "telling" in this short blog post.

In short, the standard `ApiFetcherArgs` which are passed to the `api` handler extending the exported `InitClientArgs`. Both `ApiFetcherArgs` and `InitClientArgs` are available type exports from `ts-rest`.

Let's show some type definitions from my current version of TS Rest to show what we have to work with.

For `ApiFetcher` with is the type of `api`:

```ts
export type ApiFetcher = (args: ApiFetcherArgs) => Promise<{
  status: number;
  body: unknown;
  headers: Headers;
}>;
```

So we know from this function signature that whatever we pass for our custom client must return a promise with `status`, `body` and `headers`.

As for the `args` property of type `ApiFetcherArgs`, we can actually extend this ourselves. TS Rest docs also have an [entire section demonstrating this](https://ts-rest.com/docs/core/custom#extra-query-arguments):

```ts
const client = initClient(contract, {
  baseUrl: "http://localhost:5003",
  baseHeaders: {},
  api: async (args: ApiFetcherArgs & { myCustomArg?: string }) => {
    if (args.myCustomArg) {
      // do something with myCustomArg ✨
    }

    return tsRestFetchApi(args);
  },
});
```

As long as we follow that contractual obligation, we can customize things however we want.

## Enabling retries, timeouts and more

At this point, we can show some code.

It's worth highlighting: this code is a **rough proof-of-concept and nothing special**. It will illustrate enough to get the point across, but be sure to make adjustments as your needs suit.

The code I will show you is a class that has a `handler` method which meets the contractual obligation to be used.


  Why a class? The TS Rest docs demonstrates passing a function. In my own work,
  I generally use a dependency injection library like{" "}
  <a href="https://github.com/inversify/InversifyJS">InversifyJS</a>, which you
  could use to inject dependencies into our custom fetch client below.

It's purely just to demonstrate something different to the docs.



```ts
import type { ApiFetcherArgs } from "@ts-rest/core";
import axios, { isAxiosError } from "axios";
import type { AxiosError, AxiosRequestConfig } from "axios";
import { merge } from "es-toolkit";

const BASE_MS = 100;
const MAX_MS = 10000;
const JITTER_FACTOR = 0.5;

interface TsRestClientConfig {
  span?: { name: string; options?: Record<string, unknown> };
  shouldRetry?: (error: AxiosError) => boolean; // Custom function to determine retry
  retries?: number;
  timeout?: number; // in milliseconds
  retryableStatuses?: number[]; // Add specific status codes to retry
  retryDelay?: {
    baseMs?: number; // Base delay in milliseconds (default: 100)
    maxMs?: number; // Maximum delay cap in milliseconds (default: 10000)
    jitterFactor?: number; // How much jitter to add (0-1, default: 0.5)
  };
}

type ExtendedApiFetcherArgs = TsRestClientConfig & ApiFetcherArgs;

const configDefaults = {
  retries: 0,
  retryDelay: {
    baseMs: BASE_MS,
    maxMs: MAX_MS,
    jitterFactor: JITTER_FACTOR,
  },
  retryableStatuses: [408, 429, 500, 502, 503, 504],
};

export class TsRestClient {
  private config: TsRestClientConfig;
  private axiosRequestConfig: AxiosRequestConfig;

  constructor(
    config: TsRestClientConfig = {},
    axiosRequestConfig: AxiosRequestConfig = {}
  ) {
    this.config = config;
    this.axiosRequestConfig = axiosRequestConfig;
  }

  public handler = async (extendedApiFetcherArgs: ExtendedApiFetcherArgs) => {
    return this.tryExecute({
      extendedApiFetcherArgs,
      attempt: 0,
    });
  };

  private async tryExecute({
    extendedApiFetcherArgs,
    attempt,
  }: {
    extendedApiFetcherArgs: ExtendedApiFetcherArgs;
    attempt: number;
  }) {
    const args = merge(this.config, extendedApiFetcherArgs);
    const {
      span,
      timeout,
      retries = configDefaults.retries,
      retryDelay = configDefaults.retryDelay,
    } = args;

    try {
      const axiosConfig: AxiosRequestConfig = {
        ...this.axiosRequestConfig,
        url: args.path,
        method: args.method,
        headers: args.headers,
        data: args.body,
        timeout,
      };

      const response = await axios(axiosConfig);

      if (span) {
        // TODO: End span
      }

      return {
        headers: response.headers,
        status: response.status,
        body: response.data,
      };
    } catch (error) {
      if (attempt < retries && this.isRetryable(error)) {
        const baseDelay = 2 ** (attempt + 1) * (retryDelay.baseMs ?? BASE_MS);
        const jitter =
          1 -
          (retryDelay.jitterFactor ?? JITTER_FACTOR) +
          Math.random() * 2 * (retryDelay.jitterFactor ?? JITTER_FACTOR);
        const delay = Math.min(
          Math.floor(baseDelay * jitter),
          retryDelay.maxMs ?? MAX_MS
        );

        console.log(
          `Retrying in ${delay}ms (attempt ${attempt + 1} of ${retries})...`
        );
        await new Promise((resolve) => setTimeout(resolve, delay));

        return this.tryExecute({
          extendedApiFetcherArgs,
          attempt: attempt + 1,
        });
      }

      if (span) {
        // TODO: End span
      }

      if (isAxiosError(error) && error.response) {
        return {
          headers: error.response.headers,
          status: error.response.status,
          body: error.response.data,
        };
      }

      throw error;
    }
  }

  private isRetryable(error: unknown): boolean {
    if (this.config.shouldRetry && isAxiosError(error))
      return this.config.shouldRetry(error);
    if (isAxiosError(error)) {
      return (
        !error.response ||
        (
          this.config.retryableStatuses ?? configDefaults.retryableStatuses
        ).includes(error.response.status)
      );
    }
    return (
      error instanceof Error &&
      (error.message.includes("timeout") ||
        error.message.includes("network") ||
        error.message.includes("connection"))
    );
  }
}
```


  You may notice that `handler` is an arrow function. This is just to do with
  binding `this` properties. You can do this another way if you wish by
  explicitly binding `this`.


In the above code, we have some helpers to determine whether a code is "retry-able", we also pass a `timeout` argument property to the Axios client where Axios can control the abort signals for our calls and we leave some placeholder space for starting and stopping "spans".

An example of implementing our client could now look like this:

```ts
import { initClient } from "@ts-rest/core";
import { contract } from "./contract";
import { TsRestClient } from "./rpc-class";

async function main() {
  // Initialize the client with the custom API
  const client = initClient(contract, {
    baseUrl: "http://localhost:3000",
    baseHeaders: {},
    api: new TsRestClient().handler,
  });

  // Example usage with helper options
  const result = await client.authors.getAuthors({
    retries: 3,
    span: { name: "fetch-authors", options: { operation: "read" } },
    timeout: 10000,
  });
  console.log(result.status);

  switch (result.status) {
    case 200:
      console.log(result.body);
      break;
    case 404:
      console.log(result.body);
      break;
    case 500:
      console.log(result.body);
      break;
  }
}

main();
```

In the above code, I pass the `api` code the `handler` method property of a new `TsRestClient` instance.

More notably, it is type safe for my to pass custom arguments for `retries`, `span` and `timeout`.

In the case of my contract, I expect I can return 200, 404 or 500s. If I run the code, I get the following for each scenario:

```sh
# 200
npx tsx src/client.ts
Starting span: fetch-authors
attempt 0
Ending span: fetch-authors
200
[ { id: '123', email: 'example@example.com', name: 'Test' } ]

# 404
npx tsx src/client.ts
Starting span: fetch-authors
attempt 0
Ending span: fetch-authors
404
{ message: 'Not found' }

# 500
$ npx tsx src/client.ts
Starting span: fetch-authors
attempt 0
Retrying in 245ms (attempt 1 of 3)...
attempt 1
Retrying in 312ms (attempt 2 of 3)...
attempt 2
Retrying in 1007ms (attempt 3 of 3)...
attempt 3
Ending span: fetch-authors
500
{ message: 'Internal server error' }
```

Other than the fact that my logging is zero-indexed (oops), you can now see that each code has different behaviour based on the responses.

In the case of my 200, I have a span start, span end and log the contract result from my test server.

The 404 behaves in a similar manner, but returns with **not found** after just one attempt. This follows my default configuration behind what are the retryable statuses.

Finally, the 500 demonstrates the retries. I makes **four attempts** in total, each with some exponential back-off based on my settings.

Our current API allows for custom overrides per invocation. So if we wanted to, we could enable 404s to be retryable.

An example:

```ts
const result = await client.authors.getAuthors({
  retries: 3,
  retryableStatuses: [404],
  span: { name: "fetch-authors", options: { operation: "read" } },
  timeout: 10000,
});
```

Running this, we can see that I ended up making one retry attempt before getting the 200:

```sh
$ npx tsx src/client.ts
Retrying in 120ms (attempt 1 of 3)...
200
[ { id: '123', email: 'example@example.com', name: 'Test' } ]
```


  In general, I wouldn't be retrying on 404s without good reason. Understand
  your system before doing so or you might negatively impact your network
  traffic.


## Improvements

As mentioned, we have placeholders for spans there and I haven't touched on caching.

It would be trivial to augment this to use an in-memory cache that you can opt into and extend our API options to include cache options.

As for the spans, you're more likely to be using something like Otel. In that case, you might have a configuration like the following:

```ts
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { Resource } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import { AWSXRayIdGenerator } from "@opentelemetry/id-generator-aws-xray";
import { AWSXRayPropagator } from "@opentelemetry/propagator-aws-xray";
import { AWSXRayExporter } from "@opentelemetry/exporter-aws-xray";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";

import { trace, context, propagation } from "@opentelemetry/api";

// Create the tracer provider with AWS X-Ray ID generator
const provider = new NodeTracerProvider({
  idGenerator: new AWSXRayIdGenerator(),
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: "my-node-service",
  }),
});

// Set the X-Ray exporter
provider.addSpanProcessor(new SimpleSpanProcessor(new AWSXRayExporter()));

// Register the provider
provider.register();

// Set the global propagator to X-Ray format
propagation.setGlobalPropagator(new AWSXRayPropagator());

// Optional: register all known instrumentations
registerInstrumentations({
  instrumentations: [getNodeAutoInstrumentations()],
});

// Now you can use OpenTelemetry as usual
const tracer = trace.getTracer("my-node-service");
```

Once you have a tracer available, you can start and stop spans like so:

```ts
const tracer = trace.getTracer("my-node-service");

// Somewhere in your business logic
const span = tracer.startSpan("fraudCheck");

context.with(trace.setSpan(context.active(), span), () => {
  try {
    // do fraud check
    if (someRiskyCondition) {
      span.setAttribute("fraud.score", 95);
    }
    span.addEvent("fraud check complete");
  } catch (err) {
    span.recordException(err);
    span.setStatus({ code: SpanStatusCode.ERROR });
  } finally {
    span.end();
  }
});
```

Instead of passing in the span arguments we made, you might pass in the span or context itself and have your client manage its lifecycle or attribute properties.

## Conclusion

Short post today. I wanted to just show off a proof-of-concept that I had in order to illustrate that you can encapsulate some of that timeout and retry logic into your custom client logic so that others can make use it in their projects when calling your servers.

## Links and Further Reading

- [TS Rest](https://ts-rest.com/)
- [Zod](https://zod.dev/)
- [Tanstack Query](https://tanstack.com/query/latest)
- [Custom Clients in TS Rest](https://ts-rest.com/docs/core/custom)
- [Extra Query Arguments in TS Rest](https://ts-rest.com/docs/core/custom#extra-query-arguments)
- [Axios Introduction](https://axios-http.com/docs/intro)
- [InversifyJS](https://github.com/inversify/InversifyJS)

_Photo credit: [`mickdepaola`](https://unsplash.com/@mickdepaola)_
