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.
Home page: https://dfsyncjs.github.io
Full documentation: https://dfsyncjs.github.io/#/docs/client
npm install @dfsync/clientimport { 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',
});@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.
- 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, andDELETE - response validation with
ValidationError - idempotency key support for safer retries
It provides a predictable and controllable HTTP request lifecycle for service-to-service communication.
A request in @dfsync/client follows a predictable lifecycle:
- create request context
- build final URL from
baseUrl,path, and optional query params - merge client and request headers
- apply authentication
- attach request metadata (e.g.
x-request-id) - run
beforeRequesthooks - send request with
fetch - run
onRetrybefore a retry attempt - retry on failure (if configured)
- parse response (JSON, text, or
undefinedfor204) - validate response data (if configured)
- run
afterResponseoronErrorhooks
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.
Each request has a requestId that is:
- automatically generated by default
- can be overridden per request
- propagated via the
x-request-idheader
await client.get('/users', {
requestId: 'req_123',
});You can also override the header directly:
await client.get('/users', {
headers: {
'x-request-id': 'custom-id',
},
});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
@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.
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.
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',
},
);@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 (
backofforretry-after)
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
See the project roadmap