Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/fiery-shrimps-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@dfsync/client': minor
---

Add request lifecycle support.

This release introduces a predictable and controllable request lifecycle for service-to-service communication.

New features:

- AbortSignal support with proper cancellation handling
- request context with execution metadata (requestId, attempt, startedAt, signal)
- request ID propagation via `x-request-id`
- improved lifecycle hook context

Additional improvements:

- distinguish between timeout and manual cancellation (`TimeoutError` vs `RequestAbortedError`)
- external aborts are not retried
- clearer request metadata handling
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,23 @@ Home page:
Full documentation:
[https://dfsyncjs.github.io/#/docs](https://dfsyncjs.github.io/#/docs)

#### Main features:
#### Main features

- typed responses
- predictable request lifecycle
- request ID propagation (`x-request-id`)
- request cancellation via `AbortSignal`
- built-in retry with configurable policies
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
- request timeout support

- typed responses
- automatic JSON parsing
- consistent error handling
- auth support: `bearer`, `API key`, custom
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
- retry policies

- auth support: bearer, API key, custom
- support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`

**@dfsync/client** provides a predictable and controllable HTTP request lifecycle for service-to-service communication.

#### Built for modern backend systems

Expand All @@ -73,11 +81,13 @@ Example:
import { createClient } from '@dfsync/client';

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

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

## Project Structure
Expand All @@ -104,5 +114,4 @@ smoke/

## Roadmap

See the project roadmap:
https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md
See the [project roadmap](https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md)
6 changes: 4 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A lightweight HTTP client for reliable service-to-service communication.

Focus: method surface and developer experience.

Status: mostly completed
Status: completed

Delivered:

Expand All @@ -22,7 +22,9 @@ Delivered:

**Focus**: request control and lifecycle management.

Planned features:
Status: completed

Delivered:

- AbortSignal support (extended and stabilized)
- request context object for passing metadata through lifecycle
Expand Down
104 changes: 89 additions & 15 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,30 +60,104 @@ client.request(config)

## Main features

- typed responses
- predictable request lifecycle
- request ID propagation (`x-request-id`)
- request cancellation via `AbortSignal`
- built-in retry with configurable policies
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
- request timeout support

- typed responses
- automatic JSON parsing
- consistent error handling

- auth support: bearer, API key, custom
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
- support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`
- retry policies

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

## How requests work

A request in `@dfsync/client` follows this flow:
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. retry on failure (if configured)
9. parse response (JSON, text, or `undefined` for `204`)
10. 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

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

You can also override the header directly:

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

## Request cancellation

Requests can be cancelled using `AbortSignal`:

```ts
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 provides structured error types:

- `HttpError` — non-2xx responses
- `NetworkError` — network failures
- `TimeoutError` — request timed out
- `RequestAbortedError` — request was cancelled

1. build final URL from `baseUrl`, `path`, and optional query params
2. merge default, client-level, and request-level headers
3. apply auth configuration
4. run `beforeRequest` hooks
5. send request with `fetch`
6. if the request fails with a retryable error, retry according to the configured retry policy
7. parse response as JSON, text, or `undefined` for `204`
8. throw structured errors for failed requests
9. run `afterResponse` or `onError` hooks
This allows you to handle failures more precisely.

## Roadmap

See the project roadmap:
https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md
See the [project roadmap](https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md)
10 changes: 10 additions & 0 deletions packages/client/src/core/create-request-controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export type RequestAbortReason = 'timeout' | 'external';

export type RequestController = {
signal: AbortSignal;
cleanup: () => void;
getAbortReason: () => RequestAbortReason | undefined;
};

type CreateRequestControllerParams = {
Expand All @@ -11,7 +14,10 @@ type CreateRequestControllerParams = {
export function createRequestController(params: CreateRequestControllerParams): RequestController {
const timeoutController = new AbortController();

let abortReason: RequestAbortReason | undefined;

const timeoutId = setTimeout(() => {
abortReason = 'timeout';
timeoutController.abort();
}, params.timeout);

Expand All @@ -21,14 +27,17 @@ export function createRequestController(params: CreateRequestControllerParams):
cleanup: () => {
clearTimeout(timeoutId);
},
getAbortReason: () => abortReason,
};
}

if (params.signal.aborted) {
abortReason = 'external';
timeoutController.abort();
}

const abortOnExternalSignal = () => {
abortReason = 'external';
timeoutController.abort();
};

Expand All @@ -40,5 +49,6 @@ export function createRequestController(params: CreateRequestControllerParams):
clearTimeout(timeoutId);
params.signal?.removeEventListener('abort', abortOnExternalSignal);
},
getAbortReason: () => abortReason,
};
}
2 changes: 1 addition & 1 deletion packages/client/src/core/execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function createExecutionContext(params: CreateExecutionContextParams): Ex
headers: params.headers,
attempt: params.attempt,

requestId: generateRequestId(),
requestId: params.request.requestId ?? generateRequestId(),
startedAt: Date.now(),
};
}
20 changes: 13 additions & 7 deletions packages/client/src/core/hook-context.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,39 @@
import type { AfterResponseContext, BeforeRequestContext, ErrorContext } from '../types/hooks';
import type { ExecutionContext } from './execution-context';

export function createBeforeRequestContext(execution: ExecutionContext): BeforeRequestContext {
function createLifecycleContextBase(
execution: ExecutionContext,
): Omit<BeforeRequestContext, never> {
return {
request: execution.request,
url: execution.url,
headers: execution.headers,
attempt: execution.attempt,
requestId: execution.requestId,
startedAt: execution.startedAt,
signal: execution.request.signal,
};
}

export function createBeforeRequestContext(execution: ExecutionContext): BeforeRequestContext {
return createLifecycleContextBase(execution);
}

export function createAfterResponseContext<T>(
execution: ExecutionContext,
response: Response,
data: T,
): AfterResponseContext<T> {
return {
request: execution.request,
url: execution.url,
headers: execution.headers,
...createLifecycleContextBase(execution),
response,
data,
};
}

export function createErrorContext(execution: ExecutionContext, error: Error): ErrorContext {
return {
request: execution.request,
url: execution.url,
headers: execution.headers,
...createLifecycleContextBase(execution),
error,
};
}
12 changes: 11 additions & 1 deletion packages/client/src/core/normalize-error.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { HttpError } from '../errors/http-error';
import { NetworkError } from '../errors/network-error';
import { TimeoutError } from '../errors/timeout-error';
import { RequestAbortedError } from '../errors/request-aborted-error';
import type { RequestAbortReason } from './create-request-controller';

export function normalizeError(error: unknown, timeout: number): Error {
export function normalizeError(
error: unknown,
timeout: number,
abortReason?: RequestAbortReason,
): Error {
if (error instanceof HttpError) {
return error;
}

if (error instanceof Error && error.name === 'AbortError') {
if (abortReason === 'external') {
return new RequestAbortedError(error);
}

return new TimeoutError(timeout, error);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/core/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export async function request<T>(
throw new HttpError(response, data);
}
} catch (rawError) {
const error = normalizeError(rawError, timeout);
const error = normalizeError(rawError, timeout, requestController.getAbortReason());
lastError = error;

const canRetry = shouldRetry({
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/errors/request-aborted-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { DfsyncError } from './base-error';

export class RequestAbortedError extends DfsyncError {
constructor(cause?: unknown) {
super('Request was aborted', 'REQUEST_ABORTED', cause);
this.name = 'RequestAbortedError';
}
}
18 changes: 9 additions & 9 deletions packages/client/src/types/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import type { HeadersMap } from './common';
import type { RequestConfig } from './request';

export type BeforeRequestContext = {
type LifecycleContextBase = {
request: RequestConfig;
url: URL;
headers: HeadersMap;
attempt: number;
requestId: string;
startedAt: number;
signal?: AbortSignal | undefined;
};

export type AfterResponseContext<T = unknown> = {
request: RequestConfig;
url: URL;
headers: HeadersMap;
export type BeforeRequestContext = LifecycleContextBase;

export type AfterResponseContext<T = unknown> = LifecycleContextBase & {
response: Response;
data: T;
};

export type ErrorContext = {
request: RequestConfig;
url: URL;
headers: HeadersMap;
export type ErrorContext = LifecycleContextBase & {
error: Error;
};

Expand Down
1 change: 1 addition & 0 deletions packages/client/src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type RequestConfig = {
timeout?: number;
retry?: RetryConfig;
signal?: AbortSignal;
requestId?: string;
};

export type RequestOptionsWithoutBody = Omit<RequestConfig, 'method' | 'path' | 'body'>;
Expand Down
Loading