# @regcheq/http-client

Librería interna de cliente HTTP para todos los proyectos de Regcheq (Node.js y browser). Reemplaza el uso directo de axios con un middleware centralizado: retries, timeouts, logging, correlation ID y fix de latencia en Node 22 — todo en un solo lugar, sin repetirlo en cada servicio.


## Objetivos

- Un solo `import` en cada servicio; sin boilerplate de configuración.
- Fix automático de latencia en Node 22 (keep-alive + orden DNS `ipv4first`).
- Comportamiento consistente en todos los servicios: mismos defaults, mismo manejo de errores.
- Si hay que cambiar algo global (timeout, headers, retry strategy), se cambia en esta librería — no en 16 repos.


## Instalación

```bash
npm i @regcheq/http-client
```


## Uso básico

El export principal es `client` — un singleton que gestiona automáticamente un pool de conexiones por origin. Úsalo igual que axios:

```ts
import { client } from '@regcheq/http-client';

// GET
const data = await client.get<Empresa>(`${process.env.API_MAIN}/empresa/123`);

// POST
const created = await client.post<Empresa>(`${process.env.API_MAIN}/empresa`, { nombre: 'Regcheq' });

// PUT
const updated = await client.put<Empresa>(`${process.env.API_MAIN}/empresa/123`, { nombre: 'Regcheq SA' });

// PATCH
const patched = await client.patch<Empresa>(`${process.env.API_MAIN}/empresa/123`, { nombre: 'Regcheq SA' });

// DELETE
await client.delete(`${process.env.API_MAIN}/empresa/123`);
```

No hay que configurar nada. La librería crea y reutiliza automáticamente los pools de conexión por origin.


## Requisitos para desarrollo

El proyecto usa **Node 22**. Con [nvm](https://github.com/nvm-sh/nvm) instalado:

```bash
nvm use
npm ci
```

**Husky** ejecuta antes de cada commit:

- **pre-commit**: `typecheck`, `lint` y `test` — no se permite commit si alguno falla.
- **commit-msg**: **commitlint** para que los mensajes sigan [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, etc.).


## Guía rápida por framework


### NestJS

Registra `HttpClientModule` globalmente y usa `HttpClientService`:

```ts
// app.module.ts
import { HttpClientModule } from '@regcheq/http-client';

@Module({
  imports: [HttpClientModule],
})
export class AppModule {}
```

```ts
// cualquier-service.service.ts
import { HttpClientService } from '@regcheq/http-client';

@Injectable()
export class ReportesService {
  constructor(private readonly http: HttpClientService) {}

  async obtenerReporte(id: string) {
    return this.http.get<Reporte>(`${process.env.API_INFORMES}/reportes/${id}`);
  }

  async crearReporte(data: CreateReporteDto) {
    return this.http.post<Reporte>(`${process.env.API_INFORMES}/reportes`, data);
  }
}
```

**Cliente dedicado por servicio (para timeout o retries distintos):**

```ts
import { createClient, HttpClientError } from '@regcheq/http-client';
import type { OnModuleDestroy } from '@nestjs/common';

@Injectable()
export class CargaMasivaService implements OnModuleDestroy {
  private api = createClient(process.env.API_FILES!);

  onModuleDestroy() {
    return this.api.destroy();
  }

  async subirArchivo(data: Buffer): Promise<UploadResult> {
    return this.api.post<UploadResult>('/archivo/carga-masiva/errors', data, {
      headers: { 'Content-Type': 'application/octet-stream' },
    });
  }

  async obtenerArchivo(id: string): Promise<Buffer> {
    return this.api.buffer(`/archivo/${id}`);
  }
}
```

**Con timeout personalizado por llamada:**

```ts
// Para uploads grandes donde el timeout global de 10s no alcanza
async subirExcel(file: Buffer) {
  return this.api.post<UploadResult>('/upload/excel', file, {
    headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
    timeout: 60_000,
  });
}
```

**Con retries activados para un endpoint específico:**

```ts
const apiConRetry = createClient(process.env.API_LISTAS!, { retries: 2 });
```


### LoopBack

No hay módulo especial. Usar `client` directamente o `createClient` para opciones custom:

```ts
import { client, createClient, HttpClientError } from '@regcheq/http-client';

export class MiddlewareListasService {
  // Para servicios externos más lentos, cliente dedicado con timeout propio
  private api = createClient(process.env.MIDDLEWARE_LISTAS_URL!, {
    timeout: 15_000,
  });

  async consultarLista(rut: string): Promise<ListaResult> {
    return this.api.post<ListaResult>('/consulta', { rut });
  }

  async consultarConTimeout(rut: string): Promise<ListaResult> {
    return this.api.post<ListaResult>('/consulta-batch', { rut }, {
      timeout: 30_000, // sobreescribe solo esta llamada
    });
  }
}
```

**Múltiples destinos — usar `client` con URL completa:**

```ts
export class GatewayService {
  async procesarSolicitud(id: string) {
    const [empresa, archivos] = await Promise.all([
      client.get<Empresa>(`${process.env.API_MAIN}/empresa/${id}`),
      client.get<Archivo[]>(`${process.env.API_FILES}/archivos?empresaId=${id}`),
    ]);
    return { empresa, archivos };
  }
}
```


### Express

```ts
import express from 'express';
import { client, HttpClientError } from '@regcheq/http-client';

const app = express();

app.get('/empresa/:id', async (req, res, next) => {
  try {
    const empresa = await client.get<Empresa>(`${process.env.API_MAIN}/empresa/${req.params.id}`);
    res.json(empresa);
  } catch (err) {
    next(err);
  }
});

app.post('/webhook/forward', async (req, res, next) => {
  try {
    const result = await client.post<unknown>(req.body.callbackUrl, req.body.payload, {
      timeout: 5_000,
    });
    res.json(result);
  } catch (err) {
    next(err);
  }
});
```


### Vue 2

```ts
// src/plugins/http.ts
import Vue from 'vue';
import { client } from '@regcheq/http-client';

Vue.prototype.$http = client;

// src/main.ts
import './plugins/http';
```

```ts
// En un componente
export default Vue.extend({
  data() {
    return { empresa: null as Empresa | null, error: null as string | null };
  },
  async created() {
    try {
      this.empresa = await this.$http.get<Empresa>(`${process.env.VUE_APP_API_URL}/empresa/${this.id}`);
    } catch (err) {
      if (err instanceof HttpClientError) {
        this.error = `Error ${err.statusCode}`;
      }
    }
  },
});
```

**En Vuex:**

```ts
import { client } from '@regcheq/http-client';

export const actions = {
  async fetchEmpresa({ commit }, id: string) {
    const empresa = await client.get<Empresa>(`${process.env.VUE_APP_API_URL}/empresa/${id}`);
    commit('SET_EMPRESA', empresa);
  },
};
```


### React

```ts
// src/hooks/useEmpresa.ts
import { useState, useEffect } from 'react';
import { client, HttpClientError } from '@regcheq/http-client';

export function useEmpresa(id: string) {
  const [empresa, setEmpresa] = useState<Empresa | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    client.get<Empresa>(`${import.meta.env.VITE_API_URL}/empresa/${id}`)
      .then(setEmpresa)
      .catch((err) => {
        if (err instanceof HttpClientError) {
          setError(`Error ${err.statusCode}`);
        }
      })
      .finally(() => setLoading(false));
  }, [id]);

  return { empresa, error, loading };
}
```

**Con React Query:**

```ts
import { useQuery, useMutation } from '@tanstack/react-query';
import { client } from '@regcheq/http-client';

export function useEmpresaQuery(id: string) {
  return useQuery({
    queryKey: ['empresa', id],
    queryFn: () => client.get<Empresa>(`${import.meta.env.VITE_API_URL}/empresa/${id}`),
  });
}

export function useCrearEmpresa() {
  return useMutation({
    mutationFn: (data: CreateEmpresaDto) =>
      client.post<Empresa>(`${import.meta.env.VITE_API_URL}/empresa`, data),
  });
}
```


## Manejo de errores

Todos los errores (HTTP 4xx/5xx y de red) se normalizan en `HttpClientError`:

```ts
import { HttpClientError } from '@regcheq/http-client';

try {
  const data = await client.get<Empresa>(`${process.env.API_MAIN}/empresa/123`);
} catch (err) {
  if (err instanceof HttpClientError) {
    console.log(err.statusCode);     // 404, 500, 0 (error de red)
    console.log(err.body);           // body parseado si era JSON, string si no
    console.log(err.isNetworkError); // true si no hubo respuesta del servidor
    console.log(err.message);        // 'HTTP 404'
  }
}
```

**Diferencias con axios:**

```ts
// ANTES (axios)
} catch (err) {
  if (axios.isAxiosError(err)) {
    console.log(err.response?.status);
    console.log(err.response?.data);
  }
}

// DESPUÉS (@regcheq/http-client)
} catch (err) {
  if (err instanceof HttpClientError) {
    console.log(err.statusCode);
    console.log(err.body);
  }
}
```


## Configuración de timeouts

**Por variable de entorno (aplica a todos los clientes):**
```env
HTTP_TIMEOUT_MS=10000
```

**Al crear un cliente dedicado (sobreescribe la variable de entorno):**
```ts
const api = createClient(process.env.API_LENTA!, { timeout: 30_000 });
```

**Por llamada individual:**
```ts
await client.post('/upload', file, { timeout: 60_000 });
```

**Prioridad:** llamada > cliente dedicado > variable de entorno > default (10 segundos).


## Configuración de retries

Por defecto no hay retries. Los retries solo aplican a errores de red (`isNetworkError = true`), **nunca** a errores HTTP (4xx/5xx).

```ts
// Sin retries (default)
const api = createClient(process.env.API_URL!);

// Con 2 retries para un servicio con intermitencia
const apiConRetry = createClient(process.env.API_LISTAS!, { retries: 2 });
```

El delay entre reintentos usa backoff exponencial: `200ms`, `400ms`, `800ms`...

**Por variable de entorno:**
```env
HTTP_RETRIES=2
HTTP_RETRY_DELAY_MS=200
```


## Correlation ID (propagación automática)

En servicios que reciben un `x-request-id` y hacen llamadas a otros servicios, usar `runWithCorrelationId` para propagarlo automáticamente:

```ts
import { runWithCorrelationId } from '@regcheq/http-client';

// En el middleware de tu servicio (NestJS/Express/LoopBack)
app.use((req, res, next) => {
  const requestId = req.headers['x-request-id'] as string ?? crypto.randomUUID();
  runWithCorrelationId(requestId, () => next());
});
```

Todas las llamadas HTTP dentro de ese contexto incluirán el `x-request-id` automáticamente.


## Migración desde axios

**Llamada directa:**

```ts
// ANTES
import axios from 'axios';
const response = await axios.get(`${process.env.API_FILES}/archivo/${id}`);
const data = response.data; // axios envuelve en { data }

// DESPUÉS
import { client } from '@regcheq/http-client';
const data = await client.get<Archivo>(`${process.env.API_FILES}/archivo/${id}`);
// retorna el body directamente — quitar .data
```

**axios.create() → createClient():**

```ts
// ANTES
const api = axios.create({ baseURL: process.env.API_FILES, timeout: 8000 });
const response = await api.post('/archivo', payload);
const data = response.data;

// DESPUÉS
import { createClient } from '@regcheq/http-client';
const api = createClient(process.env.API_FILES!, { timeout: 8000 });
const data = await api.post<UploadResult>('/archivo', payload);
```

**Diferencias clave:**

| Comportamiento | axios | @regcheq/http-client |
|---|---|---|
| Respuesta exitosa | `response.data` | valor directo |
| Error HTTP | `err.response.status` + `err.response.data` | `err.statusCode` + `err.body` |
| Body en POST/PUT/PATCH | segundo argumento | segundo argumento |
| Content-Type | manual | automático si body es objeto |
| Timeout | `{ timeout: ms }` | `{ timeout: ms }` |
| Cancelación | `CancelToken` (deprecated) | `{ signal: AbortSignal }` |


## Variables de entorno disponibles

Todos tienen valores por defecto — solo definir si se necesita sobreescribir:

```env
HTTP_TIMEOUT_MS=10000          # Timeout global en ms (default: 10000)
HTTP_MAX_CONNECTIONS=10        # Conexiones por pool (default: 10, solo Node.js)
HTTP_RETRIES=0                 # Reintentos ante error de red (default: 0)
HTTP_RETRY_DELAY_MS=200        # Delay base del backoff exponencial (default: 200)
HTTP_LOG_REQUESTS=false        # Log de cada request/response (default: false)
HTTP_CORRELATION_HEADER=x-request-id  # Header de correlación (default: x-request-id)
```


## Notas de rendimiento en Node 22

Esta librería aplica automáticamente los fixes necesarios para Node 22:

- **Keep-alive**: las conexiones TCP se reutilizan (undici Pool). Con axios sin esta librería cada request abría y cerraba una conexión, causando +100-500ms de overhead por request en K8s.
- **DNS `ipv4first`**: Node 22 cambió el orden DNS a `verbatim`, causando que en Kubernetes los lookups IPv6 fallaran primero y después hicieran fallback a IPv4 (+50-200ms en DNS frío). Esta librería revierte al comportamiento de Node 18.

Estos dos fixes se aplican globalmente al importar la librería — no requieren configuración adicional.


## Versioning (SemVer)

Este paquete sigue SemVer:

- `PATCH`: fixes internos sin cambios de API.
- `MINOR`: nuevas funcionalidades compatibles.
- `MAJOR`: cambios que rompen compatibilidad.


## Commits (Conventional Commits)

El release automático usa Conventional Commits. Ejemplos:

- `feat: agregar soporte para FormData` → MINOR
- `fix: corregir timeout en pool de conexiones` → PATCH
- `feat!: cambiar API de createClient` → MAJOR

Tipos comunes: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`.
