Retries, timeouts and spans for TS Rest clients
Published: Apr 10, 2025
Last updated: Apr 10, 2025
Working with TS Rest is great for any workflows where your consumers are TypeScript frontend and backend applications.
If you work extensively with Zod and tools like Tanstack Query (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 which can be used for controlling how your HTTP calls are made.
As a simple example:
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:
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
:
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:
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 InversifyJS, which you could use to inject dependencies into our custom fetch client below.
It's purely just to demonstrate something different to the docs.
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:
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:
# 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:
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:
$ 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:
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:
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
Photo credit: mickdepaola
Retries, timeouts and spans for TS Rest clients
Introduction