@dfsync/client
TypeScript icon, indicating that this package has built-in type declarations

0.8.0 • Public • Published

@dfsync/client

A lightweight and reliable HTTP client for service-to-service communication in Node.js, with built-in retry, authentication, and lifecycle hooks.

Designed for backend services, microservices and internal APIs where consistent and reliable HTTP communication between services is required.

npm version npm downloads License: MIT

Home page: https://dfsyncjs.github.io

Full documentation: https://dfsyncjs.github.io/#/docs/client

Install

npm install @dfsync/client

Quick Start

import { createClient } from '@dfsync/client';

const client = createClient({
  baseUrl: 'https://api.example.com',
  retry: { attempts: 3 },
});

const users = await client.get('/users');

const createdUser = await client.post('/users', {
  name: 'John',
});

const updatedUser = await client.patch('/users/1', {
  name: 'Jane',
});

HTTP methods

@dfsync/client provides a small and predictable method surface:

client.get(path, options?)
client.delete(path, options?)

client.post(path, body?, options?)
client.put(path, body?, options?)
client.patch(path, body?, options?)

client.request(config)

get and delete do not accept body in options.

post, put, and patch accept request body as a separate second argument.

Main features

  • predictable request lifecycle
  • request ID propagation (x-request-id)
  • request cancellation via AbortSignal
  • built-in retry with configurable policies
  • lifecycle hooks: beforeRequest, afterResponse, onRetry, onError
  • request timeout support
  • typed responses
  • automatic JSON parsing
  • consistent error handling
  • auth support: bearer, API key, custom
  • support for GET, POST, PUT, PATCH, and DELETE
  • response validation with ValidationError
  • idempotency key support for safer retries

It provides a predictable and controllable HTTP request lifecycle for service-to-service communication.

How requests work

A request in @dfsync/client follows a predictable lifecycle:

  1. create request context
  2. build final URL from baseUrl, path, and optional query params
  3. merge client and request headers
  4. apply authentication
  5. attach request metadata (e.g. x-request-id)
  6. run beforeRequest hooks
  7. send request with fetch
  8. run onRetry before a retry attempt
  9. retry on failure (if configured)
  10. parse response (JSON, text, or undefined for 204)
  11. validate response data (if configured)
  12. run afterResponse or onError hooks

Request context

Each request is executed within a request context that contains:

  • requestId — unique identifier for the request
  • attempt — current retry attempt
  • signal — AbortSignal for cancellation
  • startedAt — request start timestamp

This context is available in all lifecycle hooks.

Request ID

Each request has a requestId that is:

  • automatically generated by default
  • can be overridden per request
  • propagated via the x-request-id header

Example

await client.get('/users', {
  requestId: 'req_123',
});

You can also override the header directly:

await client.get('/users', {
  headers: {
    'x-request-id': 'custom-id',
  },
});

Request cancellation

Requests can be cancelled using AbortSignal:

const controller = new AbortController();

const promise = client.get('/users', {
  signal: controller.signal,
});

controller.abort();

Cancellation is treated differently from timeouts:

  • timeout → TimeoutError
  • manual cancellation → RequestAbortedError

Errors

@dfsync/client provides structured error types:

  • HttpError — non-2xx responses
  • NetworkError — network failures
  • TimeoutError — request timed out
  • ValidationError — response validation failed
  • RequestAbortedError — request was cancelled

This allows you to handle failures more precisely.

Response validation

You can validate successful responses before they are returned to the caller.

This is useful when your service depends on another API and needs to fail fast when the response shape changes unexpectedly. Instead of passing malformed data deeper into your application, validation turns the mismatch into a structured ValidationError.

Validation runs only after a successful HTTP response. Non-2xx responses still throw HttpError.

import { createClient } from '@dfsync/client';

const client = createClient({
  baseUrl: 'https://api.example.com',
  validateResponse(data) {
    return typeof data === 'object' && data !== null && 'id' in data;
  },
});

const user = await client.get('/users/1');

Return false to fail validation. Returning true or nothing means validation passed.

You can also override validation per request:

await client.get('/users/1', {
  validateResponse(data) {
    return typeof data === 'object' && data !== null && 'email' in data;
  },
});

When validation fails, @dfsync/client throws ValidationError:

import { ValidationError } from '@dfsync/client';

try {
  await client.get('/users/1');
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(error.data);
  }
}

Validation failures are not retried by default.

Idempotency keys

For operations that may be retried safely, you can attach an idempotency key per request.

This helps protect non-idempotent operations, such as payments or job creation, from being applied more than once when a request is retried after a transient failure. The receiving service should treat repeated requests with the same idempotency key as the same logical operation.

await client.post(
  '/payments',
  { amount: 100 },
  {
    idempotencyKey: 'payment-123',
  },
);

This adds the following header:

idempotency-key: payment-123

POST and PATCH requests are not retried unless both conditions are true:

  • the method is explicitly included in retry.retryMethods
  • the request provides idempotencyKey

By default, POST and PATCH are not retried. This keeps unsafe retries opt-in and makes the retry behavior explicit at the call site.

const client = createClient({
  baseUrl: 'https://api.example.com',
  retry: {
    attempts: 3,
    retryMethods: ['POST'],
    retryOn: ['5xx'],
  },
});

await client.post(
  '/payments',
  { amount: 100 },
  {
    idempotencyKey: 'payment-123',
  },
);

Observability

@dfsync/client provides built-in request lifecycle metadata for better visibility and debugging.

Each request exposes:

  • requestId — stable identifier across retries
  • attempt / maxAttempts — retry progress
  • startedAt / endedAt / durationMs — timing information
  • retryReason — why a retry happened (network-error, 5xx, 429)
  • retryDelayMs — delay before the next retry
  • retrySource — delay source (backoff or retry-after)

Example

const client = createClient({
  baseUrl: 'https://api.example.com',
  retry: {
    attempts: 2,
    retryOn: ['5xx'],
  },
  hooks: {
    onRetry(ctx) {
      console.log({
        requestId: ctx.requestId,
        attempt: ctx.attempt,
        maxAttempts: ctx.maxAttempts,
        delay: ctx.retryDelayMs,
        reason: ctx.retryReason,
        source: ctx.retrySource,
      });
    },
  },
});

When response validation is configured and passes, afterResponse also receives validation metadata.

const client = createClient({
  baseUrl: 'https://api.example.com',
  validateResponse(data) {
    return typeof data === 'object' && data !== null && 'id' in data;
  },
  hooks: {
    afterResponse(ctx) {
      console.log(ctx.validation);
      // { enabled: true, passed: true }
    },
  },
});

This makes it easier to understand:

  • what happened during a request
  • how retries behaved
  • how long requests actually took

Roadmap

See the project roadmap