Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/lovely-lands-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@dfsync/client': minor
---

- add integration safety features
- add response validation with `ValidationError`
- expose validation result in lifecycle hooks
- add idempotency key support
- improve retry safety for non-idempotent requests
40 changes: 40 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# dfsync — Monorepo Guidelines

This repository is a monorepo using pnpm workspaces.

## Structure

- packages/client — HTTP client for service-to-service communication
- (future packages will be added here)

## Rules

- Always detect which package you are working in
- NEVER apply changes across packages unless explicitly asked
- Keep changes scoped to a single package

## Package-specific context

When working in a package, you MUST read its local CLAUDE.md file:

- packages/client/CLAUDE.md

If a package does not have CLAUDE.md:

- follow root rules only
- do NOT invent architecture

## Development principles

- Do NOT introduce breaking changes
- Do NOT refactor unrelated code
- Prefer minimal, incremental changes
- Follow existing patterns

## When unsure

- Ask instead of guessing

## Important

When editing files inside a package, ALWAYS prefer local CLAUDE.md over this file.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Home page:
[https://dfsyncjs.github.io](https://dfsyncjs.github.io)

Full documentation:
[https://dfsyncjs.github.io/#/docs](https://dfsyncjs.github.io/#/docs)
[https://dfsyncjs.github.io/#/docs/client](https://dfsyncjs.github.io/#/docs/client)

#### Main features

Expand All @@ -55,6 +55,8 @@ Full documentation:

- auth support: bearer, API key, custom
- support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`
- response validation
- idempotency key support for safer retries

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

Expand Down
11 changes: 7 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,15 @@ Delivered:

Focus: safer and more predictable integrations.

Status: in progress
Status: completed

Planned features:
Delivered:

- response validation (schema-based or custom)
- idempotency key support for safe retries
- response validation with client-level defaults and request-level overrides
- `ValidationError` for failed response validation
- validation result metadata in lifecycle hooks
- idempotency key support via the `idempotency-key` header
- safer retry behavior for non-idempotent requests

### 0.9.x — Platform readiness & API stabilization

Expand Down
83 changes: 83 additions & 0 deletions packages/client/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# @dfsync/client — Development Context

This package provides a reliable HTTP client for service-to-service communication.

## Scope

- Node.js / TypeScript HTTP client
- Focus on predictable request lifecycle
- Used in microservices and integrations

## Architecture

Core flow:

1. createExecutionContext
2. applyRequestMetadata (headers, requestId)
3. fetch
4. parseResponse
5. error handling (HttpError / NetworkError / etc.)
6. response validation (if configured)
7. hooks execution

## Key principles

### Error handling

- All internal errors must extend `DfsyncError`
- NEVER wrap `DfsyncError` into `NetworkError`
- Preserve original error types

### Validation

- Runs only after successful HTTP responses
- Throws `ValidationError`
- MUST NOT trigger retries

### Retry

- Applies only to:
- network errors
- 5xx responses
- 429 responses
- Must remain predictable

### Hooks

- Do NOT change existing hook signatures
- New fields must be optional

### Tests

- Integration tests for request lifecycle
- Unit tests for isolated logic
- Use `getFirstMockCall` from testUtils

## Code style

- Follow existing structure in src/core
- Do NOT introduce new layers
- Prefer extending existing flow

## Important

Do NOT:

- refactor request pipeline
- change retry behavior unless task explicitly requires it
- introduce new abstractions

## When implementing features

- Keep PRs small and focused
- One concern per PR

## Current focus

Release: 0.8.x — Integration safety

Areas:

- response validation
- idempotency
- safe retries
128 changes: 124 additions & 4 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Designed for backend services, microservices and internal APIs where consistent

Home page: [https://dfsyncjs.github.io](https://dfsyncjs.github.io)

Full documentation: [https://dfsyncjs.github.io/#/docs](https://dfsyncjs.github.io/#/docs)
Full documentation: [https://dfsyncjs.github.io/#/docs/client](https://dfsyncjs.github.io/#/docs/client)

## Install

Expand Down Expand Up @@ -71,6 +71,8 @@ client.request(config)
- 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.

Expand All @@ -88,7 +90,8 @@ A request in `@dfsync/client` follows a predictable lifecycle:
8. run `onRetry` before a retry attempt
9. retry on failure (if configured)
10. parse response (JSON, text, or `undefined` for `204`)
11. run `afterResponse` or `onError` hooks
11. validate response data (if configured)
12. run `afterResponse` or `onError` hooks

## Request context

Expand Down Expand Up @@ -148,18 +151,118 @@ Cancellation is treated differently from timeouts:

## Errors

dfsync provides structured error types:
`@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`.

```ts
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:

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

When validation fails, `@dfsync/client` throws `ValidationError`:

```ts
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.

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

This adds the following header:

```text
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.

```ts
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 provides built-in request lifecycle metadata for better visibility and debugging.
`@dfsync/client` provides built-in request lifecycle metadata for better visibility and debugging.

Each request exposes:

Expand Down Expand Up @@ -194,6 +297,23 @@ const client = createClient({
});
```

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

```ts
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
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/core/apply-request-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@ import type { ExecutionContext } from './execution-context';

export function applyRequestMetadata(execution: ExecutionContext): void {
execution.headers['x-request-id'] = execution.headers['x-request-id'] ?? execution.requestId;

if (execution.request.idempotencyKey) {
execution.headers['idempotency-key'] =
execution.headers['idempotency-key'] ?? execution.request.idempotencyKey;
}
}
2 changes: 2 additions & 0 deletions packages/client/src/core/execution-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HeadersMap } from '../types/common';
import type { ResponseValidationResult } from '../types/hooks';
import type { RequestConfig } from '../types/request';

export type ExecutionContext = {
Expand All @@ -11,6 +12,7 @@ export type ExecutionContext = {
startedAt: number;
endedAt?: number;
durationMs?: number;
validation?: ResponseValidationResult;
};

type CreateExecutionContextParams = {
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/core/hook-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function createAfterResponseContext<T>(
...createLifecycleContextBase(execution),
response,
data,
...(execution.validation !== undefined ? { validation: execution.validation } : {}),
};
}

Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/core/normalize-error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HttpError } from '../errors/http-error';
import { DfsyncError } from '../errors/base-error';
import { NetworkError } from '../errors/network-error';
import { TimeoutError } from '../errors/timeout-error';
import { RequestAbortedError } from '../errors/request-aborted-error';
Expand All @@ -9,7 +9,7 @@ export function normalizeError(
timeout: number,
abortReason?: RequestAbortReason,
): Error {
if (error instanceof HttpError) {
if (error instanceof DfsyncError) {
return error;
}

Expand Down
Loading
Loading