Skip to content

Commit b51448f

Browse files
authored
release: 0.6.x (#48)
1 parent 03378a6 commit b51448f

22 files changed

Lines changed: 584 additions & 51 deletions

.changeset/fiery-shrimps-beg.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@dfsync/client': minor
3+
---
4+
5+
Add request lifecycle support.
6+
7+
This release introduces a predictable and controllable request lifecycle for service-to-service communication.
8+
9+
New features:
10+
11+
- AbortSignal support with proper cancellation handling
12+
- request context with execution metadata (requestId, attempt, startedAt, signal)
13+
- request ID propagation via `x-request-id`
14+
- improved lifecycle hook context
15+
16+
Additional improvements:
17+
18+
- distinguish between timeout and manual cancellation (`TimeoutError` vs `RequestAbortedError`)
19+
- external aborts are not retried
20+
- clearer request metadata handling

README.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,23 @@ Home page:
4040
Full documentation:
4141
[https://dfsyncjs.github.io/#/docs](https://dfsyncjs.github.io/#/docs)
4242

43-
#### Main features:
43+
#### Main features
4444

45-
- typed responses
45+
- predictable request lifecycle
46+
- request ID propagation (`x-request-id`)
47+
- request cancellation via `AbortSignal`
48+
- built-in retry with configurable policies
49+
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
4650
- request timeout support
51+
52+
- typed responses
4753
- automatic JSON parsing
4854
- consistent error handling
49-
- auth support: `bearer`, `API key`, custom
50-
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
51-
- retry policies
55+
56+
- auth support: bearer, API key, custom
57+
- support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`
58+
59+
**@dfsync/client** provides a predictable and controllable HTTP request lifecycle for service-to-service communication.
5260

5361
#### Built for modern backend systems
5462

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

7583
const client = createClient({
76-
baseUrl: 'https://api.example.com',
84+
baseURL: 'https://api.example.com',
7785
retry: { attempts: 3 },
7886
});
7987

80-
const users = await client.get('/users');
88+
const users = await client.get('/users', {
89+
requestId: 'req_123',
90+
});
8191
```
8292

8393
## Project Structure
@@ -104,5 +114,4 @@ smoke/
104114

105115
## Roadmap
106116

107-
See the project roadmap:
108-
https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md
117+
See the [project roadmap](https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md)

ROADMAP.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ A lightweight HTTP client for reliable service-to-service communication.
1010

1111
Focus: method surface and developer experience.
1212

13-
Status: mostly completed
13+
Status: completed
1414

1515
Delivered:
1616

@@ -22,7 +22,9 @@ Delivered:
2222

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

25-
Planned features:
25+
Status: completed
26+
27+
Delivered:
2628

2729
- AbortSignal support (extended and stabilized)
2830
- request context object for passing metadata through lifecycle

packages/client/README.md

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,30 +60,104 @@ client.request(config)
6060

6161
## Main features
6262

63-
- typed responses
63+
- predictable request lifecycle
64+
- request ID propagation (`x-request-id`)
65+
- request cancellation via `AbortSignal`
66+
- built-in retry with configurable policies
67+
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
6468
- request timeout support
69+
70+
- typed responses
6571
- automatic JSON parsing
6672
- consistent error handling
73+
6774
- auth support: bearer, API key, custom
68-
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
6975
- support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`
70-
- retry policies
76+
77+
It provides a predictable and controllable HTTP request lifecycle for service-to-service communication.
7178

7279
## How requests work
7380

74-
A request in `@dfsync/client` follows this flow:
81+
A request in `@dfsync/client` follows a predictable lifecycle:
82+
83+
1. create request context
84+
2. build final URL from `baseUrl`, `path`, and optional query params
85+
3. merge client and request headers
86+
4. apply authentication
87+
5. attach request metadata (e.g. `x-request-id`)
88+
6. run `beforeRequest` hooks
89+
7. send request with `fetch`
90+
8. retry on failure (if configured)
91+
9. parse response (JSON, text, or `undefined` for `204`)
92+
10. run `afterResponse` or `onError` hooks
93+
94+
## Request context
95+
96+
Each request is executed within a request context that contains:
97+
98+
- `requestId` — unique identifier for the request
99+
- `attempt` — current retry attempt
100+
- `signal` — AbortSignal for cancellation
101+
- `startedAt` — request start timestamp
102+
103+
This context is available in all lifecycle hooks.
104+
105+
## Request ID
106+
107+
Each request has a `requestId` that is:
108+
109+
- automatically generated by default
110+
- can be overridden per request
111+
- propagated via the `x-request-id` header
112+
113+
### Example
114+
115+
```ts
116+
await client.get('/users', {
117+
requestId: 'req_123',
118+
});
119+
```
120+
121+
You can also override the header directly:
122+
123+
```ts
124+
await client.get('/users', {
125+
headers: {
126+
'x-request-id': 'custom-id',
127+
},
128+
});
129+
```
130+
131+
## Request cancellation
132+
133+
Requests can be cancelled using `AbortSignal`:
134+
135+
```ts
136+
const controller = new AbortController();
137+
138+
const promise = client.get('/users', {
139+
signal: controller.signal,
140+
});
141+
142+
controller.abort();
143+
```
144+
145+
Cancellation is treated differently from timeouts:
146+
147+
- timeout → `TimeoutError`
148+
- manual cancellation → `RequestAbortedError`
149+
150+
## Errors
151+
152+
dfsync provides structured error types:
153+
154+
- `HttpError` — non-2xx responses
155+
- `NetworkError` — network failures
156+
- `TimeoutError` — request timed out
157+
- `RequestAbortedError` — request was cancelled
75158

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

86161
## Roadmap
87162

88-
See the project roadmap:
89-
https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md
163+
See the [project roadmap](https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md)

packages/client/src/core/create-request-controller.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
export type RequestAbortReason = 'timeout' | 'external';
2+
13
export type RequestController = {
24
signal: AbortSignal;
35
cleanup: () => void;
6+
getAbortReason: () => RequestAbortReason | undefined;
47
};
58

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

17+
let abortReason: RequestAbortReason | undefined;
18+
1419
const timeoutId = setTimeout(() => {
20+
abortReason = 'timeout';
1521
timeoutController.abort();
1622
}, params.timeout);
1723

@@ -21,14 +27,17 @@ export function createRequestController(params: CreateRequestControllerParams):
2127
cleanup: () => {
2228
clearTimeout(timeoutId);
2329
},
30+
getAbortReason: () => abortReason,
2431
};
2532
}
2633

2734
if (params.signal.aborted) {
35+
abortReason = 'external';
2836
timeoutController.abort();
2937
}
3038

3139
const abortOnExternalSignal = () => {
40+
abortReason = 'external';
3241
timeoutController.abort();
3342
};
3443

@@ -40,5 +49,6 @@ export function createRequestController(params: CreateRequestControllerParams):
4049
clearTimeout(timeoutId);
4150
params.signal?.removeEventListener('abort', abortOnExternalSignal);
4251
},
52+
getAbortReason: () => abortReason,
4353
};
4454
}

packages/client/src/core/execution-context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function createExecutionContext(params: CreateExecutionContextParams): Ex
3030
headers: params.headers,
3131
attempt: params.attempt,
3232

33-
requestId: generateRequestId(),
33+
requestId: params.request.requestId ?? generateRequestId(),
3434
startedAt: Date.now(),
3535
};
3636
}
Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,39 @@
11
import type { AfterResponseContext, BeforeRequestContext, ErrorContext } from '../types/hooks';
22
import type { ExecutionContext } from './execution-context';
33

4-
export function createBeforeRequestContext(execution: ExecutionContext): BeforeRequestContext {
4+
function createLifecycleContextBase(
5+
execution: ExecutionContext,
6+
): Omit<BeforeRequestContext, never> {
57
return {
68
request: execution.request,
79
url: execution.url,
810
headers: execution.headers,
11+
attempt: execution.attempt,
12+
requestId: execution.requestId,
13+
startedAt: execution.startedAt,
14+
signal: execution.request.signal,
915
};
1016
}
1117

18+
export function createBeforeRequestContext(execution: ExecutionContext): BeforeRequestContext {
19+
return createLifecycleContextBase(execution);
20+
}
21+
1222
export function createAfterResponseContext<T>(
1323
execution: ExecutionContext,
1424
response: Response,
1525
data: T,
1626
): AfterResponseContext<T> {
1727
return {
18-
request: execution.request,
19-
url: execution.url,
20-
headers: execution.headers,
28+
...createLifecycleContextBase(execution),
2129
response,
2230
data,
2331
};
2432
}
2533

2634
export function createErrorContext(execution: ExecutionContext, error: Error): ErrorContext {
2735
return {
28-
request: execution.request,
29-
url: execution.url,
30-
headers: execution.headers,
36+
...createLifecycleContextBase(execution),
3137
error,
3238
};
3339
}

packages/client/src/core/normalize-error.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import { HttpError } from '../errors/http-error';
22
import { NetworkError } from '../errors/network-error';
33
import { TimeoutError } from '../errors/timeout-error';
4+
import { RequestAbortedError } from '../errors/request-aborted-error';
5+
import type { RequestAbortReason } from './create-request-controller';
46

5-
export function normalizeError(error: unknown, timeout: number): Error {
7+
export function normalizeError(
8+
error: unknown,
9+
timeout: number,
10+
abortReason?: RequestAbortReason,
11+
): Error {
612
if (error instanceof HttpError) {
713
return error;
814
}
915

1016
if (error instanceof Error && error.name === 'AbortError') {
17+
if (abortReason === 'external') {
18+
return new RequestAbortedError(error);
19+
}
20+
1121
return new TimeoutError(timeout, error);
1222
}
1323

packages/client/src/core/request.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export async function request<T>(
9393
throw new HttpError(response, data);
9494
}
9595
} catch (rawError) {
96-
const error = normalizeError(rawError, timeout);
96+
const error = normalizeError(rawError, timeout, requestController.getAbortReason());
9797
lastError = error;
9898

9999
const canRetry = shouldRetry({
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { DfsyncError } from './base-error';
2+
3+
export class RequestAbortedError extends DfsyncError {
4+
constructor(cause?: unknown) {
5+
super('Request was aborted', 'REQUEST_ABORTED', cause);
6+
this.name = 'RequestAbortedError';
7+
}
8+
}

0 commit comments

Comments
 (0)