Published on

Introducing reixo - The TypeScript HTTP Client That Replaces axios

Authors

Introduction

The native fetch API is low-level. axios fills some gaps but leaves the hard parts — circuit breaking, typed errors, offline queuing, OpenTelemetry, Result-style error handling — to third-party plugins or custom code.

reixo bundles all of those patterns into one cohesive, zero-dependency TypeScript library. This is its story.

Installation

npm install reixo
# or
yarn add reixo
# or
pnpm add reixo

Quick Start

import { HTTPBuilder } from 'reixo'

const client = new HTTPBuilder()
  .withBaseURL('https://api.example.com')
  .withTimeout(10_000)
  .withHeader('Authorization', 'Bearer <token>')
  .build()

// Traditional throwing style
const response = await client.get<User[]>('/users')
console.log(response.data)  // User[]

// No-throw Result<T, E> style — the recommended approach
const result = await client.tryGet<User[]>('/users')
if (result.ok) {
  console.log(result.data.data)  // User[]
} else {
  console.error(result.error.status)  // HTTPError.status
}

Why reixo Over axios?

1. No-Throw Result<T, E> API

The biggest quality-of-life feature — tryGet, tryPost, etc. return Ok | Err instead of throwing. No more try/catch tax:

// With axios — try/catch everywhere
try {
  const res = await axios.get('/users')
  return res.data
} catch (err) {
  if (axios.isAxiosError(err)) {
    console.error(err.response?.status)
  }
}

// With reixo — clean Result pattern
const result = await client.tryGet<User[]>('/users')
if (!result.ok) {
  console.error(result.error.status)  // Fully typed!
  return
}
return result.data.data

2. Typed Error Classes

No more string matching or isAxiosError() checks:

import {
  HTTPError,
  NetworkError,
  TimeoutError,
  AbortError,
  CircuitOpenError,
} from 'reixo'

try {
  await client.get('/api/data')
} catch (err) {
  if (err instanceof HTTPError) {
    console.error(`HTTP ${err.status}: ${err.statusText}`)
  } else if (err instanceof TimeoutError) {
    console.error(`Timed out after ${err.timeoutMs}ms`)
  } else if (err instanceof CircuitOpenError) {
    console.warn('Circuit breaker open — using fallback')
  } else if (err instanceof NetworkError) {
    console.error('Network failure:', err.message)
  }
}

3. Built-in Retry with Exponential Backoff

const client = new HTTPBuilder()
  .withBaseURL('https://api.example.com')
  .withRetry({
    maxRetries: 3,
    initialDelayMs: 200,
    backoffFactor: 2,
    jitter: true,  // Spread concurrent retries
    retryCondition: (err) => {
      if (err instanceof NetworkError) return true
      if (err instanceof HTTPError) return err.status >= 500 || err.status === 429
      return false
    },
    onRetry: (err, attempt, delayMs) => {
      console.log(`Retry #${attempt} in ${delayMs}ms`)
    },
  })
  .build()

4. Circuit Breaker

Prevents cascading failures when a downstream service is down:

const client = new HTTPBuilder()
  .withCircuitBreaker({
    failureThreshold: 5,       // Open after 5 consecutive failures
    resetTimeoutMs: 30_000,    // Attempt recovery after 30s
    onStateChange: (prev, next) => {
      console.log(`Circuit: ${prev}${next}`)
    },
  })
  .build()

// When the breaker is OPEN, requests fail immediately with CircuitOpenError
// — without hitting the network

5. Request Deduplication

Five simultaneous identical GET requests collapse into one network call:

const client = new HTTPBuilder()
  .withBaseURL('https://api.example.com')
  .withDeduplication()
  .build()

// All 5 share one Promise — only 1 network request!
const [r1, r2, r3, r4, r5] = await Promise.all([
  client.get('/config'),
  client.get('/config'),
  client.get('/config'),
  client.get('/config'),
  client.get('/config'),
])

6. W3C OpenTelemetry Tracing — Zero Config

Injects traceparent, tracestate, and baggage headers with no @opentelemetry/* dependencies:

const client = new HTTPBuilder()
  .withOpenTelemetry({
    serviceName: 'checkout-service',
    baggage: { 'user.tier': 'premium' },
  })
  .build()

// Continuing an incoming trace (in Express/Next.js/Hono)
import { parseTraceparent } from 'reixo'

const parentCtx = parseTraceparent(req.headers['traceparent'])
const tracedClient = new HTTPBuilder()
  .withOpenTelemetry({ parentContext: parentCtx ?? undefined })
  .build()

7. Smart Caching

const client = new HTTPBuilder()
  .withCache({
    ttl: 120_000,
    strategy: 'stale-while-revalidate',
    storage: 'memory',
    maxEntries: 200,
  })
  .build()

const res = await client.get('/config')
if (res.cacheMetadata?.hit) {
  console.log(`Cache hit — ${res.cacheMetadata.age}s old`)
}

8. Auth Token Refresh — Handles Concurrent 401s

import { createAuthInterceptor } from 'reixo'

// When multiple requests fail with 401 simultaneously,
// only ONE token refresh is triggered — the rest queue
const authInterceptor = createAuthInterceptor(client, {
  getAccessToken: () => localStorage.getItem('access_token'),
  refreshTokens: async () => {
    const res = await client.post('/auth/refresh', {
      refreshToken: localStorage.getItem('refresh_token'),
    })
    localStorage.setItem('access_token', res.data.accessToken)
    return res.data.accessToken
  },
  shouldRefresh: (err) => err instanceof HTTPError && err.status === 401,
})
client.addRequestInterceptor(authInterceptor)

9. Offline Queue

const client = new HTTPBuilder()
  .withOfflineQueue({
    storage: 'localStorage',  // Persist across page reloads
    maxSize: 100,
  })
  .build()

// Requests queue while offline — drain automatically on reconnect
await client.post('/events', { type: 'click', timestamp: Date.now() })

client.on('queue:drain', () => console.log('All offline requests complete'))

10. WebSocket, SSE, GraphQL Built-in

import { WebSocketClient, SSEClient, GraphQLClient } from 'reixo'

// WebSocket with auto-reconnect
const ws = new WebSocketClient({
  url: 'wss://api.example.com/ws',
  reconnect: { maxRetries: 10, initialDelayMs: 1_000 },
  heartbeat: { interval: 30_000, message: 'ping' },
})

// Server-Sent Events
const sse = new SSEClient({
  url: 'https://api.example.com/stream',
  headers: { Authorization: 'Bearer token' },
})

// Type-safe GraphQL
const gql = new GraphQLClient('https://api.example.com/graphql')
const { data } = await gql.query<{ user: User }>({
  query: `query GetUser($id: ID!) { user(id: $id) { id name } }`,
  variables: { id: '1' },
})

Migrating from axios

It's intentionally easy — the API is similar:

// Before (axios)
import axios from 'axios'
const api = axios.create({ baseURL: 'https://api.example.com' })
const res = await api.get('/users')
res.data  // User[]

// After (reixo)
import { HTTPBuilder } from 'reixo'
const api = new HTTPBuilder().withBaseURL('https://api.example.com').build()
const res = await api.get<User[]>('/users')
res.data  // User[] — same shape!

Testing with MockAdapter

import { MockAdapter, HTTPClient, HTTPError } from 'reixo'
import { describe, it, expect } from 'vitest'

const mock = new MockAdapter()
const client = new HTTPClient({ transport: mock.transport })

mock.onGet('/users').reply(200, [{ id: 1, name: 'Alice' }])
mock.onGet('/users/999').reply(404, { error: 'Not found' })

it('fetches users', async () => {
  const res = await client.get('/users')
  expect(res.status).toBe(200)
})

it('throws on 404', async () => {
  await expect(client.get('/users/999')).rejects.toBeInstanceOf(HTTPError)
})

Runtime Support

reixo works everywhere:

EnvironmentSupport
Node.js 18+
Bun 1.0+
Deno 1.28+
Cloudflare Workers
Vercel Edge
Modern Browsers

Zero dependencies — just native fetch and AbortController.

Conclusion

reixo is the HTTP client that modern TypeScript applications deserve. It takes all the boilerplate you've been copy-pasting across projects — retries, circuit breaking, deduplication, auth refresh, tracing — and bundles it into one cohesive, fully-typed, zero-dependency library. If you're tired of the axios ecosystem and want something that just works, give reixo a try.

npm install reixo

📦 reixo on npm | 🔗 GitHub