Skip to content
Closed
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
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
45 changes: 42 additions & 3 deletions packages/client/tests/integration/abort-signal.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from 'vitest';

import { createClient } from '../../src/core/create-client';
import { TimeoutError } from '../../src/errors/timeout-error';
import { RequestAbortedError } from '../../src/errors/request-aborted-error';
import { getFirstFetchInit } from '../testUtils';

describe('client abort signal', () => {
it('throws TimeoutError when request is aborted via external signal', async () => {
it('throws RequestAbortedError when request is aborted via external signal', async () => {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
return new Promise<Response>((_resolve, reject) => {
const rejectWithAbortError = () => {
Expand Down Expand Up @@ -39,7 +39,7 @@ describe('client abort signal', () => {

controller.abort();

await expect(promise).rejects.toBeInstanceOf(TimeoutError);
await expect(promise).rejects.toBeInstanceOf(RequestAbortedError);
});

it('passes the external signal to fetch', async () => {
Expand Down Expand Up @@ -70,4 +70,43 @@ describe('client abort signal', () => {
const init = getFirstFetchInit(fetchMock);
expect(init?.signal).toBeDefined();
});

it('does not retry when request is aborted via external signal', async () => {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
return new Promise<Response>((_resolve, reject) => {
const rejectWithAbortError = () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
reject(abortError);
};

if (init?.signal?.aborted) {
rejectWithAbortError();
return;
}

init?.signal?.addEventListener('abort', rejectWithAbortError, {
once: true,
});
});
});

const client = createClient({
baseUrl: 'https://api.test.com',
timeout: 1000,
fetch: fetchMock,
retry: { attempts: 2 },
});

const controller = new AbortController();

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

controller.abort();

await expect(promise).rejects.toBeInstanceOf(RequestAbortedError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
86 changes: 86 additions & 0 deletions packages/client/tests/integration/headers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { createClient } from '../../src/core/create-client';
import { getFirstFetchInit } from '../testUtils';

describe('request headers', () => {
it('adds default accept header', async () => {
Expand Down Expand Up @@ -101,4 +102,89 @@ describe('request headers', () => {
expect(headers['x-shared']).toBe('request');
expect(headers['x-api-key']).toBe('secret-key');
});

it('propagates requestId to x-request-id header', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);

const client = createClient({
baseUrl: 'https://api.test.com',
fetch: fetchMock,
});

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

expect(fetchMock).toHaveBeenCalledTimes(1);

const firstCall = fetchMock.mock.calls[0];
expect(firstCall).toBeDefined();

const [, init] = firstCall!;
const headers = init?.headers as Record<string, string>;

expect(headers['x-request-id']).toBe('req_123');
});

it('prefers explicit x-request-id header over requestId option', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);

const client = createClient({
baseUrl: 'https://api.test.com',
fetch: fetchMock,
});

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

expect(fetchMock).toHaveBeenCalledTimes(1);

const firstCall = fetchMock.mock.calls[0];
expect(firstCall).toBeDefined();

const [, init] = firstCall!;
const headers = init?.headers as Record<string, string>;

expect(headers['x-request-id']).toBe('req_from_header');
});

it('adds generated x-request-id header when requestId is not provided', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);

const client = createClient({
baseUrl: 'https://api.test.com',
fetch: fetchMock,
});

await client.get('/users');

expect(fetchMock).toHaveBeenCalledTimes(1);

const init = getFirstFetchInit(fetchMock);
const headers = init.headers as Record<string, string | undefined>;
const requestId = headers['x-request-id'];

expect(requestId).toBeDefined();
expect(typeof requestId).toBe('string');
expect(requestId!.length).toBeGreaterThan(0);
});
});
Loading