Jan 9, 2026
await httpRequest('users', 'GET', {
query: { page: 1, search: undefined },
headers: { 'x-source': 'web' },
timeoutMs: 8000,
retries: 3,
retryDelayMs: 200,
refreshTokenOn401: true,
baseURLOverride: process.env.EXTERNAL_API,
})export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
export type QueryValue = string | number | boolean | null | undefined
export type QueryParams = Record<string, QueryValue>
export type HeadersMap = Record<string, string>
export interface RequestDescriptor<TResponse = unknown> {
method: HttpMethod
url: string
headers: HeadersMap
query: QueryParams
body?: unknown
timeoutMs?: number
// Execution metadata (not Axios options directly)
retries?: number
requireAuth?: boolean
}
export class RequestBuilder<TResponse = unknown> {
private descriptor: RequestDescriptor<TResponse>
constructor() {
this.descriptor = {
method: 'GET',
url: '',
headers: {},
query: {},
retries: 0,
requireAuth: true,
}
}
setUrl(url: string) {
this.descriptor.url = url
return this
}
setMethod(method: HttpMethod) {
this.descriptor.method = method
return this
}
addHeader(key: string, value: string) {
this.descriptor.headers[key] = value
return this
}
setBody(body: unknown) {
this.descriptor.body = body
return this
}
setTimeout(timeoutMs: number) {
this.descriptor.timeoutMs = timeoutMs
return this
}
setRetries(retries: number) {
this.descriptor.retries = retries
return this
}
requireAuth(requireAuth: boolean) {
this.descriptor.requireAuth = requireAuth
return this
}
addQueryParam(key: string, value: QueryValue) {
this.descriptor.query[key] = value
return this
}
addQueryParams(params: QueryParams) {
Object.entries(params).forEach(([k, v]) => {
this.addQueryParam(k, v)
})
return this
}
build(): RequestDescriptor<TResponse> {
if (!this.descriptor.url) {
throw new Error('RequestBuilder: url is required')
}
return {
...this.descriptor,
headers: { ...this.descriptor.headers },
query: { ...this.descriptor.query },
}
}
}import { QueryParams } from './RequestBuilder'
export function buildUrlWithQuery(url: string, query: QueryParams): string {
const entries = Object.entries(query).filter(([, v]) => v !== undefined && v !== null)
if (entries.length === 0) return url
const params = new URLSearchParams()
for (const [k, v] of entries) {
params.append(k, String(v))
}
const separator = url.includes('?') ? '&' : '?'
return url + separator + params.toString()
}import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'
import { RequestDescriptor } from './RequestBuilder'
import { buildUrlWithQuery } from './buildUrlWithQuery'
type TokenProvider = () => Promise<string | null>
type RefreshToken = () => Promise<void>
function isUnauthorized(error: unknown): boolean {
return axios.isAxiosError(error) && error.response?.status === 401
}
async function withRetry<T>(
fn: () => Promise<T>,
retries: number,
delayMs: number,
): Promise<T> {
let attempt = 0
// simple retry, usually enough for most BFF/frontends
while (true) {
try {
return await fn()
} catch (err) {
attempt += 1
if (attempt > retries) throw err
await new Promise((r) => setTimeout(r, delayMs))
}
}
}
export class HttpClient {
private axios: AxiosInstance
private getToken: TokenProvider
private refreshToken: RefreshToken
constructor(params: {
baseURL: string
getToken: TokenProvider
refreshToken: RefreshToken
}) {
this.getToken = params.getToken
this.refreshToken = params.refreshToken
this.axios = axios.create({
baseURL: params.baseURL,
})
// Auth interceptor: inject token only when the request requires it
this.axios.interceptors.request.use(async (config) => {
const requireAuth = (config.headers as any)?.['x-require-auth'] === 'true'
if (!requireAuth) return config
const token = await this.getToken()
if (token) {
config.headers = {
...(config.headers || {}),
Authorization: 'Bearer ' + token,
}
}
return config
})
}
async request<TResponse>(descriptor: RequestDescriptor<TResponse>): Promise<TResponse> {
const url = buildUrlWithQuery(descriptor.url, descriptor.query)
const config: AxiosRequestConfig = {
url,
method: descriptor.method,
headers: {
...descriptor.headers,
// internal flag, consumed by the interceptor
'x-require-auth': descriptor.requireAuth ? 'true' : 'false',
},
data: descriptor.body,
timeout: descriptor.timeoutMs,
}
const exec = async () => {
try {
const res = await this.axios.request<TResponse>(config)
return res.data
} catch (err) {
if (isUnauthorized(err) && descriptor.requireAuth) {
await this.refreshToken()
const res = await this.axios.request<TResponse>(config)
return res.data
}
throw err
}
}
return withRetry(exec, descriptor.retries ?? 0, 200)
}
}type User = { id: string; name: string }
type GetAllUsersQuery = {
page?: number
search?: string
active?: boolean
}
export class UsersService {
constructor(private http: HttpClient) {}
async getAll(query: GetAllUsersQuery = {}): Promise<User[]> {
const descriptor = new RequestBuilder<User[]>()
.setUrl('/users')
.setMethod('GET')
.addQueryParams({
page: query.page,
search: query.search,
active: query.active,
})
.setTimeout(8000)
.setRetries(2)
.build()
return this.http.request(descriptor)
}
}