1000fetches 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -89
- package/dist/client/index.d.ts +23 -0
- package/dist/contract/index.d.ts +6 -0
- package/dist/core.d.ts +36 -10
- package/dist/{errors.d.ts → errors/errors.d.ts} +42 -7
- package/dist/errors/handling.d.ts +2 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -12
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/request/abort/abort.d.ts +12 -0
- package/dist/request/abort/index.d.ts +1 -0
- package/dist/request/fetchErrors/fetchErrors.d.ts +4 -0
- package/dist/request/fetchErrors/index.d.ts +1 -0
- package/dist/request/middleware/index.d.ts +1 -0
- package/dist/request/middleware/middleware.d.ts +7 -0
- package/dist/request/request.d.ts +16 -0
- package/dist/request/retry/index.d.ts +1 -0
- package/dist/request/retry/retry.d.ts +20 -0
- package/dist/request/serialization/index.d.ts +1 -0
- package/dist/request/serialization/serialization.d.ts +9 -0
- package/dist/request/url.d.ts +3 -0
- package/dist/response.d.ts +12 -0
- package/dist/retry-BWvlSmME.cjs +2 -0
- package/dist/retry-BWvlSmME.cjs.map +1 -0
- package/dist/retry-D07aIs-1.js +2 -0
- package/dist/retry-D07aIs-1.js.map +1 -0
- package/dist/{schema.d.ts → schema/index.d.ts} +1 -1
- package/dist/status.d.ts +2 -0
- package/dist/types.d.ts +26 -21
- package/dist/utils/index.d.ts +1 -2
- package/dist/utils/path.d.ts +6 -8
- package/docs/BEST_PRACTICES.md +124 -45
- package/package.json +30 -21
- package/skills/1000fetches/SKILL.md +35 -0
- package/skills/1000fetches/references/client-setup-and-defaults.md +49 -0
- package/skills/1000fetches/references/contracts-and-errors.md +50 -0
- package/skills/1000fetches/references/path-params-and-urls.md +39 -0
- package/skills/1000fetches/references/retries-timeouts-and-middleware.md +32 -0
- package/skills/1000fetches/references/serialization-and-body-types.md +22 -0
- package/dist/client-C7dvgNnD.js +0 -2
- package/dist/client-C7dvgNnD.js.map +0 -1
- package/dist/client-DOv6m2TJ.mjs +0 -2
- package/dist/client-DOv6m2TJ.mjs.map +0 -1
- package/dist/client.d.ts +0 -22
- package/dist/http.cjs +0 -2
- package/dist/http.cjs.map +0 -1
- package/dist/http.d.ts +0 -9
- package/dist/http.mjs +0 -2
- package/dist/http.mjs.map +0 -1
- package/dist/utils/streaming.d.ts +0 -9
- package/docs/API.md +0 -754
- package/docs/MIGRATION.md +0 -719
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './abort';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { HttpError } from '../../errors';
|
|
2
|
+
import { CustomFetch, ResponseType } from '../../types';
|
|
3
|
+
export declare function fetchWithNetworkError(fetchImplementation: CustomFetch, url: string, requestInit: RequestInit): Promise<Response>;
|
|
4
|
+
export declare function createHttpError(response: ResponseType<unknown>, cause?: Error): HttpError;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './fetchErrors';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './middleware';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { HttpMethod, ResponseType } from '../../types';
|
|
2
|
+
export declare function applyResponseMiddleware<TResponse>({ onResponseMiddleware, response, url, method, }: {
|
|
3
|
+
onResponseMiddleware?: (response: ResponseType<unknown>) => ResponseType<unknown> | Promise<ResponseType<unknown>>;
|
|
4
|
+
response: ResponseType<TResponse>;
|
|
5
|
+
url: string;
|
|
6
|
+
method: HttpMethod;
|
|
7
|
+
}): Promise<ResponseType<TResponse>>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CustomFetch, ExtendedRequestInit, HttpMethod, RequestOptions, ResponseType, RetryOptions } from '../types';
|
|
2
|
+
interface ExecuteHttpRequestWithRetryOptions {
|
|
3
|
+
fetchImplementation: CustomFetch;
|
|
4
|
+
requestInit: ExtendedRequestInit;
|
|
5
|
+
requestRetry?: RetryOptions | boolean;
|
|
6
|
+
clientRetry?: RetryOptions | boolean;
|
|
7
|
+
url: string;
|
|
8
|
+
method: HttpMethod;
|
|
9
|
+
timeout: number;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
responseType?: RequestOptions['responseType'];
|
|
12
|
+
onResponseMiddleware?: (response: ResponseType<unknown>) => ResponseType<unknown> | Promise<ResponseType<unknown>>;
|
|
13
|
+
isExpectedStatus: (status: number) => boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function executeHttpRequestWithRetry<TResponse>({ fetchImplementation, requestInit: baseRequestInit, requestRetry, clientRetry, url, method, timeout, signal, responseType, onResponseMiddleware, isExpectedStatus, }: ExecuteHttpRequestWithRetryOptions): Promise<ResponseType<TResponse>>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './retry';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { HttpMethod, RetryContext, RetryEvent, RetryOptions } from '../../types';
|
|
2
|
+
type RetryPolicy = {
|
|
3
|
+
maxRetries: number;
|
|
4
|
+
retryDelay: number;
|
|
5
|
+
backoffFactor: number;
|
|
6
|
+
retryStatusCodes: number[];
|
|
7
|
+
retryNetworkErrors: boolean;
|
|
8
|
+
maxRetryDelay: number;
|
|
9
|
+
retryMethods: readonly HttpMethod[];
|
|
10
|
+
retryUnsafeMethods: boolean;
|
|
11
|
+
jitter: NonNullable<RetryOptions['jitter']>;
|
|
12
|
+
respectRetryAfter: boolean;
|
|
13
|
+
shouldRetry?: RetryOptions['shouldRetry'];
|
|
14
|
+
onRetry?: RetryOptions['onRetry'];
|
|
15
|
+
};
|
|
16
|
+
export declare function createRetryPolicy(clientRetry?: RetryOptions | boolean, requestRetry?: RetryOptions | boolean): RetryPolicy | undefined;
|
|
17
|
+
export declare function shouldRetry(error: Error, retryCount: number, retryConfig: RetryPolicy | undefined, context: Pick<RetryContext, 'bodyReplayable' | 'method' | 'url'>): Promise<boolean>;
|
|
18
|
+
export declare function calculateRetryDelay(attempt: number, retryConfig: RetryPolicy | undefined, error: Error): number;
|
|
19
|
+
export declare function notifyRetry(retryConfig: RetryPolicy | undefined, event: RetryEvent): Promise<void>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './serialization';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { FetchOptions, RequestParamsType } from '../../types';
|
|
2
|
+
export declare function mergeFetchOptions(fetchOptions: FetchOptions | undefined, aliases: Pick<RequestInit, 'cache' | 'credentials' | 'mode' | 'redirect'>): FetchOptions;
|
|
3
|
+
export declare function sanitizeFetchOptions(fetchOptions: FetchOptions | undefined): FetchOptions;
|
|
4
|
+
export declare function serializeQueryParams(params: RequestParamsType): string;
|
|
5
|
+
export declare function serializeRequestBody(body: unknown): {
|
|
6
|
+
body: BodyInit;
|
|
7
|
+
contentType?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function isReplayableBody(body: BodyInit | null | undefined): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { SerializationError } from './errors';
|
|
2
|
+
import { HttpMethod, ResponseType } from './types';
|
|
3
|
+
type ParsedResponse<T> = ResponseType<T> & {
|
|
4
|
+
parseError?: SerializationError;
|
|
5
|
+
};
|
|
6
|
+
export declare function processResponse<T = unknown>(response: Response, options: {
|
|
7
|
+
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData';
|
|
8
|
+
method: HttpMethod;
|
|
9
|
+
url: string;
|
|
10
|
+
tolerateParseError?: boolean;
|
|
11
|
+
}): Promise<ParsedResponse<T>>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var t={maxRetries:3,retryDelay:300,backoffFactor:2,retryStatusCodes:[408,429,500,502,503,504],retryNetworkErrors:!0,maxRetryDelay:3e4,retryMethods:["GET","HEAD","OPTIONS","PUT","DELETE"],retryUnsafeMethods:!1,jitter:"none",respectRetryAfter:!0};function e(t){const e=t;return"HttpError"===t.name&&"number"==typeof e.status&&function(t){const e=t;return null!==e&&"object"==typeof e&&"function"==typeof e.get}(e.response?.headers)}exports.calculateRetryDelay=function(t,r,o){if(!r)return 0;const n=r.respectRetryAfter?function(t){if(!e(t))return;const r=t.response.headers.get("retry-after");if(!r)return;const o=Number(r);if(Number.isFinite(o))return Math.max(0,1e3*o);const n=Date.parse(r);if(Number.isNaN(n))return;return Math.max(0,n-Date.now())}(o):void 0;if(void 0!==n)return Math.min(n,r.maxRetryDelay);const a=r.retryDelay*Math.pow(r.backoffFactor,t);return function(t,e,r){return"function"==typeof e?Math.max(0,e(t,r)):"full"===e?Math.floor(Math.random()*t):"equal"===e?Math.floor(t/2+Math.random()*(t/2)):t}(Math.min(a,r.maxRetryDelay),r.jitter,t)},exports.createRetryPolicy=function(e,r){if(!function(t,e){return!1!==e&&(!0===t||"object"==typeof t||!0===e||"object"==typeof e)}(e,r))return;const o={...t,..."object"==typeof e?e:void 0};return!0===r?o:"object"==typeof r?{...o,...r}:o},exports.notifyRetry=async function(t,e){await(t?.onRetry?.(e))},exports.shouldRetry=async function(t,r,o,n){if(!o)return!1;const a={...n,error:t,retryCount:r};return o.shouldRetry?await o.shouldRetry(t,r,a):!(!n.bodyReplayable||!function(t,e){if(e.retryUnsafeMethods)return!0;const r=t.toUpperCase();return e.retryMethods.some(t=>t.toUpperCase()===r)}(n.method,o))&&(e(t)?o.retryStatusCodes.includes(t.status):!(!function(t){return"NetworkError"===t.name}(t)&&("TypeError"!==t.name||function(t){return t.message.includes("Invalid URL")||t.message.includes("Failed to parse URL")}(t)))&&o.retryNetworkErrors)};
|
|
2
|
+
//# sourceMappingURL=retry-BWvlSmME.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry-BWvlSmME.cjs","names":[],"sources":["../src/request/retry/retry.ts"],"sourcesContent":["import type {\n HttpMethod,\n RetryContext,\n RetryEvent,\n RetryOptions,\n} from '../../types'\n\ntype RetryPolicy = {\n maxRetries: number\n retryDelay: number\n backoffFactor: number\n retryStatusCodes: number[]\n retryNetworkErrors: boolean\n maxRetryDelay: number\n retryMethods: readonly HttpMethod[]\n retryUnsafeMethods: boolean\n jitter: NonNullable<RetryOptions['jitter']>\n respectRetryAfter: boolean\n shouldRetry?: RetryOptions['shouldRetry']\n onRetry?: RetryOptions['onRetry']\n}\n\ntype HeaderReader = {\n get(name: string): string | null\n}\n\ntype HttpErrorLike = Error & {\n status: number\n response: {\n headers: HeaderReader\n }\n}\n\ntype HttpErrorCandidate = Error & {\n status?: unknown\n response?: {\n headers?: unknown\n }\n}\n\nconst DEFAULT_RETRY_POLICY: Omit<RetryPolicy, 'shouldRetry' | 'onRetry'> = {\n maxRetries: 3,\n retryDelay: 300,\n backoffFactor: 2,\n retryStatusCodes: [408, 429, 500, 502, 503, 504],\n retryNetworkErrors: true,\n maxRetryDelay: 30_000,\n retryMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'],\n retryUnsafeMethods: false,\n jitter: 'none',\n respectRetryAfter: true,\n}\n\nfunction shouldUseRetryPolicy(\n clientRetry?: RetryOptions | boolean,\n requestRetry?: RetryOptions | boolean\n): boolean {\n return (\n requestRetry !== false &&\n (clientRetry === true ||\n typeof clientRetry === 'object' ||\n requestRetry === true ||\n typeof requestRetry === 'object')\n )\n}\n\nexport function createRetryPolicy(\n clientRetry?: RetryOptions | boolean,\n requestRetry?: RetryOptions | boolean\n): RetryPolicy | undefined {\n if (!shouldUseRetryPolicy(clientRetry, requestRetry)) {\n return undefined\n }\n\n const clientRetryOptions =\n typeof clientRetry === 'object' ? clientRetry : undefined\n const baseRetryOptions = {\n ...DEFAULT_RETRY_POLICY,\n ...clientRetryOptions,\n }\n\n if (requestRetry === true) {\n return baseRetryOptions\n }\n\n if (typeof requestRetry === 'object') {\n return {\n ...baseRetryOptions,\n ...requestRetry,\n }\n }\n\n return baseRetryOptions\n}\n\nexport async function shouldRetry(\n error: Error,\n retryCount: number,\n retryConfig: RetryPolicy | undefined,\n context: Pick<RetryContext, 'bodyReplayable' | 'method' | 'url'>\n): Promise<boolean> {\n if (!retryConfig) return false\n\n const retryContext: RetryContext = {\n ...context,\n error,\n retryCount,\n }\n\n if (retryConfig.shouldRetry) {\n return await retryConfig.shouldRetry(error, retryCount, retryContext)\n }\n\n if (\n !context.bodyReplayable ||\n !isRetryMethodAllowed(context.method, retryConfig)\n ) {\n return false\n }\n\n if (isHttpError(error)) {\n return retryConfig.retryStatusCodes.includes(error.status)\n }\n\n if (\n isNetworkError(error) ||\n (error.name === 'TypeError' && !isInvalidUrlError(error))\n ) {\n return retryConfig.retryNetworkErrors\n }\n\n return false\n}\n\nexport function calculateRetryDelay(\n attempt: number,\n retryConfig: RetryPolicy | undefined,\n error: Error\n): number {\n if (!retryConfig) return 0\n\n const retryAfterDelay = retryConfig.respectRetryAfter\n ? getRetryAfterDelay(error)\n : undefined\n\n if (retryAfterDelay !== undefined) {\n return Math.min(retryAfterDelay, retryConfig.maxRetryDelay)\n }\n\n const delay =\n retryConfig.retryDelay * Math.pow(retryConfig.backoffFactor, attempt)\n\n return applyJitter(\n Math.min(delay, retryConfig.maxRetryDelay),\n retryConfig.jitter,\n attempt\n )\n}\n\nexport async function notifyRetry(\n retryConfig: RetryPolicy | undefined,\n event: RetryEvent\n): Promise<void> {\n await retryConfig?.onRetry?.(event)\n}\n\nfunction isRetryMethodAllowed(\n method: HttpMethod,\n retryConfig: RetryPolicy\n): boolean {\n if (retryConfig.retryUnsafeMethods) {\n return true\n }\n\n const normalizedMethod = method.toUpperCase()\n return retryConfig.retryMethods.some(\n retryMethod => retryMethod.toUpperCase() === normalizedMethod\n )\n}\n\nfunction getRetryAfterDelay(error: Error): number | undefined {\n if (!isHttpError(error)) {\n return undefined\n }\n\n const retryAfter = error.response.headers.get('retry-after')\n if (!retryAfter) {\n return undefined\n }\n\n const retryAfterSeconds = Number(retryAfter)\n if (Number.isFinite(retryAfterSeconds)) {\n return Math.max(0, retryAfterSeconds * 1000)\n }\n\n const retryAfterDate = Date.parse(retryAfter)\n if (Number.isNaN(retryAfterDate)) {\n return undefined\n }\n\n return Math.max(0, retryAfterDate - Date.now())\n}\n\nfunction applyJitter(\n delay: number,\n jitter: RetryPolicy['jitter'],\n attempt: number\n): number {\n if (typeof jitter === 'function') {\n return Math.max(0, jitter(delay, attempt))\n }\n\n if (jitter === 'full') {\n return Math.floor(Math.random() * delay)\n }\n\n if (jitter === 'equal') {\n return Math.floor(delay / 2 + Math.random() * (delay / 2))\n }\n\n return delay\n}\n\nfunction isInvalidUrlError(error: Error): boolean {\n return (\n error.message.includes('Invalid URL') ||\n error.message.includes('Failed to parse URL')\n )\n}\n\nfunction isHttpError(error: Error): error is HttpErrorLike {\n const candidate = error as HttpErrorCandidate\n\n return (\n error.name === 'HttpError' &&\n typeof candidate.status === 'number' &&\n isHeaderReader(candidate.response?.headers)\n )\n}\n\nfunction isNetworkError(error: Error): boolean {\n return error.name === 'NetworkError'\n}\n\nfunction isHeaderReader(value: unknown): value is HeaderReader {\n const candidate = value as { get?: unknown }\n\n return (\n candidate !== null &&\n typeof candidate === 'object' &&\n typeof candidate.get === 'function'\n )\n}\n"],"mappings":"AAwCA,IAAM,EAAqE,CACzE,WAAY,EACZ,WAAY,IACZ,cAAe,EACf,iBAAkB,CAAC,IAAK,IAAK,IAAK,IAAK,IAAK,KAC5C,oBAAoB,EACpB,cAAe,IACf,aAAc,CAAC,MAAO,OAAQ,UAAW,MAAO,UAChD,oBAAoB,EACpB,OAAQ,OACR,mBAAmB,GAoLrB,SAAS,EAAY,GACnB,MAAM,EAAY,EAElB,MACiB,cAAf,EAAM,MACsB,iBAArB,EAAU,QASrB,SAAwB,GACtB,MAAM,EAAY,EAElB,OACgB,OAAd,GACqB,iBAAd,GACkB,mBAAlB,EAAU,GAErB,CAhBI,CAAe,EAAU,UAAU,QAEvC,6BAxGA,SACE,EACA,EACA,GAEA,IAAK,EAAa,OAAO,EAEzB,MAAM,EAAkB,EAAY,kBAuCtC,SAA4B,GAC1B,IAAK,EAAY,GACf,OAGF,MAAM,EAAa,EAAM,SAAS,QAAQ,IAAI,eAC9C,IAAK,EACH,OAGF,MAAM,EAAoB,OAAO,GACjC,GAAI,OAAO,SAAS,GAClB,OAAO,KAAK,IAAI,EAAuB,IAApB,GAGrB,MAAM,EAAiB,KAAK,MAAM,GAClC,GAAI,OAAO,MAAM,GACf,OAGF,OAAO,KAAK,IAAI,EAAG,EAAiB,KAAK,MAC3C,CA3DM,CAAmB,QACnB,EAEJ,QAAwB,IAApB,EACF,OAAO,KAAK,IAAI,EAAiB,EAAY,eAG/C,MAAM,EACJ,EAAY,WAAa,KAAK,IAAI,EAAY,cAAe,GAE/D,OAmDF,SACE,EACA,EACA,GAEA,MAAsB,mBAAX,EACF,KAAK,IAAI,EAAG,EAAO,EAAO,IAGpB,SAAX,EACK,KAAK,MAAM,KAAK,SAAW,GAGrB,UAAX,EACK,KAAK,MAAM,EAAQ,EAAI,KAAK,UAAY,EAAQ,IAGlD,CACT,CArES,CACL,KAAK,IAAI,EAAO,EAAY,eAC5B,EAAY,OACZ,EAEJ,4BA3FA,SACE,EACA,GAEA,IAjBF,SACE,EACA,GAEA,OACmB,IAAjB,KACiB,IAAhB,GACwB,iBAAhB,IACU,IAAjB,GACwB,iBAAjB,EAEb,CAMO,CAAqB,EAAa,GACrC,OAGF,MAEM,EAAmB,IACpB,KAFoB,iBAAhB,EAA2B,OAAc,GAMlD,OAAqB,IAAjB,EACK,EAGmB,iBAAjB,EACF,IACF,KACA,GAIA,CACT,sBAkEA,eACE,EACA,SAEM,GAAa,UAAU,GAC/B,sBArEA,eACE,EACA,EACA,EACA,GAEA,IAAK,EAAa,OAAO,EAEzB,MAAM,EAA6B,IAC9B,EACH,QACA,cAGF,OAAI,EAAY,kBACD,EAAY,YAAY,EAAO,EAAY,MAIvD,EAAQ,iBAoDb,SACE,EACA,GAEA,GAAI,EAAY,mBACd,OAAO,EAGT,MAAM,EAAmB,EAAO,cAChC,OAAO,EAAY,aAAa,KAC9B,GAAe,EAAY,gBAAkB,EAEjD,CA/DK,CAAqB,EAAQ,OAAQ,MAKpC,EAAY,GACP,EAAY,iBAAiB,SAAS,EAAM,WAuHvD,SAAwB,GACtB,MAAsB,iBAAf,EAAM,IACf,CArHI,CAAe,KACC,cAAf,EAAM,MAiGX,SAA2B,GACzB,OACE,EAAM,QAAQ,SAAS,gBACvB,EAAM,QAAQ,SAAS,sBAE3B,CAtGoC,CAAkB,MAE3C,EAAY,mBAIvB"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var t={maxRetries:3,retryDelay:300,backoffFactor:2,retryStatusCodes:[408,429,500,502,503,504],retryNetworkErrors:!0,maxRetryDelay:3e4,retryMethods:["GET","HEAD","OPTIONS","PUT","DELETE"],retryUnsafeMethods:!1,jitter:"none",respectRetryAfter:!0};function e(e,r){if(!function(t,e){return!1!==e&&(!0===t||"object"==typeof t||!0===e||"object"==typeof e)}(e,r))return;const n={...t,..."object"==typeof e?e:void 0};return!0===r?n:"object"==typeof r?{...n,...r}:n}async function r(t,e,r,n){if(!r)return!1;const o={...n,error:t,retryCount:e};return r.shouldRetry?await r.shouldRetry(t,e,o):!(!n.bodyReplayable||!function(t,e){if(e.retryUnsafeMethods)return!0;const r=t.toUpperCase();return e.retryMethods.some(t=>t.toUpperCase()===r)}(n.method,r))&&(a(t)?r.retryStatusCodes.includes(t.status):!(!function(t){return"NetworkError"===t.name}(t)&&("TypeError"!==t.name||function(t){return t.message.includes("Invalid URL")||t.message.includes("Failed to parse URL")}(t)))&&r.retryNetworkErrors)}function n(t,e,r){if(!e)return 0;const n=e.respectRetryAfter?function(t){if(!a(t))return;const e=t.response.headers.get("retry-after");if(!e)return;const r=Number(e);if(Number.isFinite(r))return Math.max(0,1e3*r);const n=Date.parse(e);if(Number.isNaN(n))return;return Math.max(0,n-Date.now())}(r):void 0;if(void 0!==n)return Math.min(n,e.maxRetryDelay);const o=e.retryDelay*Math.pow(e.backoffFactor,t);return function(t,e,r){return"function"==typeof e?Math.max(0,e(t,r)):"full"===e?Math.floor(Math.random()*t):"equal"===e?Math.floor(t/2+Math.random()*(t/2)):t}(Math.min(o,e.maxRetryDelay),e.jitter,t)}async function o(t,e){await(t?.onRetry?.(e))}function a(t){const e=t;return"HttpError"===t.name&&"number"==typeof e.status&&function(t){const e=t;return null!==e&&"object"==typeof e&&"function"==typeof e.get}(e.response?.headers)}export{n as calculateRetryDelay,e as createRetryPolicy,o as notifyRetry,r as shouldRetry};
|
|
2
|
+
//# sourceMappingURL=retry-D07aIs-1.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry-D07aIs-1.js","names":[],"sources":["../src/request/retry/retry.ts"],"sourcesContent":["import type {\n HttpMethod,\n RetryContext,\n RetryEvent,\n RetryOptions,\n} from '../../types'\n\ntype RetryPolicy = {\n maxRetries: number\n retryDelay: number\n backoffFactor: number\n retryStatusCodes: number[]\n retryNetworkErrors: boolean\n maxRetryDelay: number\n retryMethods: readonly HttpMethod[]\n retryUnsafeMethods: boolean\n jitter: NonNullable<RetryOptions['jitter']>\n respectRetryAfter: boolean\n shouldRetry?: RetryOptions['shouldRetry']\n onRetry?: RetryOptions['onRetry']\n}\n\ntype HeaderReader = {\n get(name: string): string | null\n}\n\ntype HttpErrorLike = Error & {\n status: number\n response: {\n headers: HeaderReader\n }\n}\n\ntype HttpErrorCandidate = Error & {\n status?: unknown\n response?: {\n headers?: unknown\n }\n}\n\nconst DEFAULT_RETRY_POLICY: Omit<RetryPolicy, 'shouldRetry' | 'onRetry'> = {\n maxRetries: 3,\n retryDelay: 300,\n backoffFactor: 2,\n retryStatusCodes: [408, 429, 500, 502, 503, 504],\n retryNetworkErrors: true,\n maxRetryDelay: 30_000,\n retryMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'],\n retryUnsafeMethods: false,\n jitter: 'none',\n respectRetryAfter: true,\n}\n\nfunction shouldUseRetryPolicy(\n clientRetry?: RetryOptions | boolean,\n requestRetry?: RetryOptions | boolean\n): boolean {\n return (\n requestRetry !== false &&\n (clientRetry === true ||\n typeof clientRetry === 'object' ||\n requestRetry === true ||\n typeof requestRetry === 'object')\n )\n}\n\nexport function createRetryPolicy(\n clientRetry?: RetryOptions | boolean,\n requestRetry?: RetryOptions | boolean\n): RetryPolicy | undefined {\n if (!shouldUseRetryPolicy(clientRetry, requestRetry)) {\n return undefined\n }\n\n const clientRetryOptions =\n typeof clientRetry === 'object' ? clientRetry : undefined\n const baseRetryOptions = {\n ...DEFAULT_RETRY_POLICY,\n ...clientRetryOptions,\n }\n\n if (requestRetry === true) {\n return baseRetryOptions\n }\n\n if (typeof requestRetry === 'object') {\n return {\n ...baseRetryOptions,\n ...requestRetry,\n }\n }\n\n return baseRetryOptions\n}\n\nexport async function shouldRetry(\n error: Error,\n retryCount: number,\n retryConfig: RetryPolicy | undefined,\n context: Pick<RetryContext, 'bodyReplayable' | 'method' | 'url'>\n): Promise<boolean> {\n if (!retryConfig) return false\n\n const retryContext: RetryContext = {\n ...context,\n error,\n retryCount,\n }\n\n if (retryConfig.shouldRetry) {\n return await retryConfig.shouldRetry(error, retryCount, retryContext)\n }\n\n if (\n !context.bodyReplayable ||\n !isRetryMethodAllowed(context.method, retryConfig)\n ) {\n return false\n }\n\n if (isHttpError(error)) {\n return retryConfig.retryStatusCodes.includes(error.status)\n }\n\n if (\n isNetworkError(error) ||\n (error.name === 'TypeError' && !isInvalidUrlError(error))\n ) {\n return retryConfig.retryNetworkErrors\n }\n\n return false\n}\n\nexport function calculateRetryDelay(\n attempt: number,\n retryConfig: RetryPolicy | undefined,\n error: Error\n): number {\n if (!retryConfig) return 0\n\n const retryAfterDelay = retryConfig.respectRetryAfter\n ? getRetryAfterDelay(error)\n : undefined\n\n if (retryAfterDelay !== undefined) {\n return Math.min(retryAfterDelay, retryConfig.maxRetryDelay)\n }\n\n const delay =\n retryConfig.retryDelay * Math.pow(retryConfig.backoffFactor, attempt)\n\n return applyJitter(\n Math.min(delay, retryConfig.maxRetryDelay),\n retryConfig.jitter,\n attempt\n )\n}\n\nexport async function notifyRetry(\n retryConfig: RetryPolicy | undefined,\n event: RetryEvent\n): Promise<void> {\n await retryConfig?.onRetry?.(event)\n}\n\nfunction isRetryMethodAllowed(\n method: HttpMethod,\n retryConfig: RetryPolicy\n): boolean {\n if (retryConfig.retryUnsafeMethods) {\n return true\n }\n\n const normalizedMethod = method.toUpperCase()\n return retryConfig.retryMethods.some(\n retryMethod => retryMethod.toUpperCase() === normalizedMethod\n )\n}\n\nfunction getRetryAfterDelay(error: Error): number | undefined {\n if (!isHttpError(error)) {\n return undefined\n }\n\n const retryAfter = error.response.headers.get('retry-after')\n if (!retryAfter) {\n return undefined\n }\n\n const retryAfterSeconds = Number(retryAfter)\n if (Number.isFinite(retryAfterSeconds)) {\n return Math.max(0, retryAfterSeconds * 1000)\n }\n\n const retryAfterDate = Date.parse(retryAfter)\n if (Number.isNaN(retryAfterDate)) {\n return undefined\n }\n\n return Math.max(0, retryAfterDate - Date.now())\n}\n\nfunction applyJitter(\n delay: number,\n jitter: RetryPolicy['jitter'],\n attempt: number\n): number {\n if (typeof jitter === 'function') {\n return Math.max(0, jitter(delay, attempt))\n }\n\n if (jitter === 'full') {\n return Math.floor(Math.random() * delay)\n }\n\n if (jitter === 'equal') {\n return Math.floor(delay / 2 + Math.random() * (delay / 2))\n }\n\n return delay\n}\n\nfunction isInvalidUrlError(error: Error): boolean {\n return (\n error.message.includes('Invalid URL') ||\n error.message.includes('Failed to parse URL')\n )\n}\n\nfunction isHttpError(error: Error): error is HttpErrorLike {\n const candidate = error as HttpErrorCandidate\n\n return (\n error.name === 'HttpError' &&\n typeof candidate.status === 'number' &&\n isHeaderReader(candidate.response?.headers)\n )\n}\n\nfunction isNetworkError(error: Error): boolean {\n return error.name === 'NetworkError'\n}\n\nfunction isHeaderReader(value: unknown): value is HeaderReader {\n const candidate = value as { get?: unknown }\n\n return (\n candidate !== null &&\n typeof candidate === 'object' &&\n typeof candidate.get === 'function'\n )\n}\n"],"mappings":"AAwCA,IAAM,EAAqE,CACzE,WAAY,EACZ,WAAY,IACZ,cAAe,EACf,iBAAkB,CAAC,IAAK,IAAK,IAAK,IAAK,IAAK,KAC5C,oBAAoB,EACpB,cAAe,IACf,aAAc,CAAC,MAAO,OAAQ,UAAW,MAAO,UAChD,oBAAoB,EACpB,OAAQ,OACR,mBAAmB,GAgBrB,SAAgB,EACd,EACA,GAEA,IAjBF,SACE,EACA,GAEA,OACmB,IAAjB,KACiB,IAAhB,GACwB,iBAAhB,IACU,IAAjB,GACwB,iBAAjB,EAEb,CAMO,CAAqB,EAAa,GACrC,OAGF,MAEM,EAAmB,IACpB,KAFoB,iBAAhB,EAA2B,OAAc,GAMlD,OAAqB,IAAjB,EACK,EAGmB,iBAAjB,EACF,IACF,KACA,GAIA,CACT,CAEA,eAAsB,EACpB,EACA,EACA,EACA,GAEA,IAAK,EAAa,OAAO,EAEzB,MAAM,EAA6B,IAC9B,EACH,QACA,cAGF,OAAI,EAAY,kBACD,EAAY,YAAY,EAAO,EAAY,MAIvD,EAAQ,iBAoDb,SACE,EACA,GAEA,GAAI,EAAY,mBACd,OAAO,EAGT,MAAM,EAAmB,EAAO,cAChC,OAAO,EAAY,aAAa,KAC9B,GAAe,EAAY,gBAAkB,EAEjD,CA/DK,CAAqB,EAAQ,OAAQ,MAKpC,EAAY,GACP,EAAY,iBAAiB,SAAS,EAAM,WAuHvD,SAAwB,GACtB,MAAsB,iBAAf,EAAM,IACf,CArHI,CAAe,KACC,cAAf,EAAM,MAiGX,SAA2B,GACzB,OACE,EAAM,QAAQ,SAAS,gBACvB,EAAM,QAAQ,SAAS,sBAE3B,CAtGoC,CAAkB,MAE3C,EAAY,mBAIvB,CAEA,SAAgB,EACd,EACA,EACA,GAEA,IAAK,EAAa,OAAO,EAEzB,MAAM,EAAkB,EAAY,kBAuCtC,SAA4B,GAC1B,IAAK,EAAY,GACf,OAGF,MAAM,EAAa,EAAM,SAAS,QAAQ,IAAI,eAC9C,IAAK,EACH,OAGF,MAAM,EAAoB,OAAO,GACjC,GAAI,OAAO,SAAS,GAClB,OAAO,KAAK,IAAI,EAAuB,IAApB,GAGrB,MAAM,EAAiB,KAAK,MAAM,GAClC,GAAI,OAAO,MAAM,GACf,OAGF,OAAO,KAAK,IAAI,EAAG,EAAiB,KAAK,MAC3C,CA3DM,CAAmB,QACnB,EAEJ,QAAwB,IAApB,EACF,OAAO,KAAK,IAAI,EAAiB,EAAY,eAG/C,MAAM,EACJ,EAAY,WAAa,KAAK,IAAI,EAAY,cAAe,GAE/D,OAmDF,SACE,EACA,EACA,GAEA,MAAsB,mBAAX,EACF,KAAK,IAAI,EAAG,EAAO,EAAO,IAGpB,SAAX,EACK,KAAK,MAAM,KAAK,SAAW,GAGrB,UAAX,EACK,KAAK,MAAM,EAAQ,EAAI,KAAK,UAAY,EAAQ,IAGlD,CACT,CArES,CACL,KAAK,IAAI,EAAO,EAAY,eAC5B,EAAY,OACZ,EAEJ,CAEA,eAAsB,EACpB,EACA,SAEM,GAAa,UAAU,GAC/B,CAkEA,SAAS,EAAY,GACnB,MAAM,EAAY,EAElB,MACiB,cAAf,EAAM,MACsB,iBAArB,EAAU,QASrB,SAAwB,GACtB,MAAM,EAAY,EAElB,OACgB,OAAd,GACqB,iBAAd,GACkB,mBAAlB,EAAU,GAErB,CAhBI,CAAe,EAAU,UAAU,QAEvC"}
|
package/dist/status.d.ts
ADDED
package/dist/types.d.ts
CHANGED
|
@@ -68,8 +68,6 @@ export interface RequestOptions<TBody = unknown> {
|
|
|
68
68
|
redirect?: RequestRedirect;
|
|
69
69
|
fetchOptions?: FetchOptions;
|
|
70
70
|
retry?: RetryOptions | boolean;
|
|
71
|
-
onUploadStreaming?: (event: UploadStreamingEvent) => void;
|
|
72
|
-
onDownloadStreaming?: (event: DownloadStreamingEvent) => void;
|
|
73
71
|
}
|
|
74
72
|
export interface ResponseType<T = unknown> {
|
|
75
73
|
data: T;
|
|
@@ -84,8 +82,30 @@ export type ExtractableResponse<T> = Promise<ResponseType<T>> & {
|
|
|
84
82
|
data(): Promise<T>;
|
|
85
83
|
void(): Promise<void>;
|
|
86
84
|
};
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
|
|
86
|
+
export type SuccessContractStatus = `${2 | 3}${Digit}${Digit}`;
|
|
87
|
+
export type ErrorContractStatus = `${4 | 5}${Digit}${Digit}`;
|
|
88
|
+
export type ContractStatus = SuccessContractStatus | ErrorContractStatus;
|
|
89
|
+
export type ContractStatusMap<Status extends string = ContractStatus> = {
|
|
90
|
+
default?: Schema;
|
|
91
|
+
} & Partial<Record<Status, Schema>>;
|
|
92
|
+
export type SuccessContractStatusMap = ContractStatusMap<SuccessContractStatus>;
|
|
93
|
+
export type ErrorContractStatusMap = ContractStatusMap<ErrorContractStatus>;
|
|
94
|
+
export type ResponseContractBranch<Status extends string = ContractStatus> = Schema | ContractStatusMap<Status>;
|
|
95
|
+
export type ResponseContract = Schema | {
|
|
96
|
+
success: ResponseContractBranch<SuccessContractStatus>;
|
|
97
|
+
error?: ResponseContractBranch<ErrorContractStatus>;
|
|
98
|
+
};
|
|
99
|
+
type InferContractBranchSchema<Branch> = Branch extends Schema ? Branch : Branch extends object ? Extract<Branch[keyof Branch], Schema> : never;
|
|
100
|
+
export type InferContractBranchOutput<Branch> = InferSchemaOutput<InferContractBranchSchema<Branch>>;
|
|
101
|
+
export type InferContractSuccess<Contract> = Contract extends Schema ? InferSchemaOutput<Contract> : Contract extends {
|
|
102
|
+
success: infer Success;
|
|
103
|
+
} ? InferContractBranchOutput<Success> : unknown;
|
|
104
|
+
export type InferContractError<Contract> = Contract extends {
|
|
105
|
+
error: infer ErrorBranch;
|
|
106
|
+
} ? InferContractBranchOutput<ErrorBranch> : unknown;
|
|
107
|
+
export type ContractableResponse<T> = ExtractableResponse<T> & {
|
|
108
|
+
contract<C extends ResponseContract>(contract: C): ExtractableResponse<InferContractSuccess<C>>;
|
|
89
109
|
};
|
|
90
110
|
/**
|
|
91
111
|
* Request context that can be modified by onRequest hook
|
|
@@ -111,7 +131,7 @@ export interface ExtendedRequestInit extends RequestInit {
|
|
|
111
131
|
}
|
|
112
132
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | (string & {});
|
|
113
133
|
export type HttpHeaders = Record<string, string>;
|
|
114
|
-
export type RequestParamsType = Record<string, string | number | boolean | (string | number | boolean | undefined | null)[] | undefined | null>;
|
|
134
|
+
export type RequestParamsType = Record<string, string | number | boolean | Date | (string | number | boolean | undefined | null)[] | readonly Date[] | undefined | null>;
|
|
115
135
|
export type Schema<T = unknown> = StandardSchemaV1<unknown, T> | (StandardSchemaV1<unknown, T> & {
|
|
116
136
|
_output: T;
|
|
117
137
|
}) | (StandardSchemaV1<unknown, T> & {
|
|
@@ -124,22 +144,7 @@ export type InferSchemaOutput<ResponseSchema> = ResponseSchema extends unknown ?
|
|
|
124
144
|
} ? Output : ResponseSchema extends {
|
|
125
145
|
_output?: infer Output;
|
|
126
146
|
} ? Output : ResponseSchema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<ResponseSchema> : unknown : never;
|
|
127
|
-
export interface UploadStreamingEvent {
|
|
128
|
-
/** Current data chunk being uploaded */
|
|
129
|
-
chunk: Uint8Array;
|
|
130
|
-
/** Total bytes to upload (from Content-Length header or body size) */
|
|
131
|
-
totalBytes: number | undefined;
|
|
132
|
-
/** Bytes already transferred */
|
|
133
|
-
transferredBytes: number;
|
|
134
|
-
}
|
|
135
|
-
export interface DownloadStreamingEvent {
|
|
136
|
-
/** Current data chunk being downloaded */
|
|
137
|
-
chunk: Uint8Array;
|
|
138
|
-
/** Total bytes to download (from Content-Length header, undefined if unknown) */
|
|
139
|
-
totalBytes: number | undefined;
|
|
140
|
-
/** Bytes already transferred */
|
|
141
|
-
transferredBytes: number;
|
|
142
|
-
}
|
|
143
147
|
export type SerializeBody<TBody = unknown> = (body: TBody) => BodyInit | null | undefined;
|
|
144
148
|
export type SerializeParams = (params: RequestParamsType) => string;
|
|
145
149
|
export type CustomFetch = typeof fetch;
|
|
150
|
+
export {};
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export { type AssertSupportedPath,
|
|
2
|
-
export { toStreamableRequest, toStreamableResponse } from './streaming';
|
|
1
|
+
export { type AssertSupportedPath, type HasRequiredParams, type RequirePathParams, } from './path';
|
package/dist/utils/path.d.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Strips protocol from URL (e.g., https://, http://)
|
|
3
|
-
*/
|
|
4
|
-
type StripProtocol<T extends string> = T extends `${string}://${infer After}` ? After : T;
|
|
5
1
|
/**
|
|
6
2
|
* Strips host and optional port from URL, keeping only the path
|
|
7
|
-
* Handles cases like "localhost:3000/users" -> "/users"
|
|
8
3
|
*/
|
|
9
4
|
type StripHost<T extends string> = T extends `${infer _Host}/${infer Path}` ? `/${Path}` : T;
|
|
5
|
+
/**
|
|
6
|
+
* Strips protocol and host from full URLs, keeping bare relative paths intact.
|
|
7
|
+
*/
|
|
8
|
+
type StripUrlOrigin<T extends string> = T extends `${string}://${infer After}` ? StripHost<After> : T;
|
|
10
9
|
/**
|
|
11
10
|
* Strips query string from path (e.g., "/users?x=1" -> "/users")
|
|
12
11
|
* Only strips ? that comes after the path, not ? that's part of path parameters
|
|
@@ -15,7 +14,7 @@ type StripQuery<T extends string> = T extends `${infer Path}?${infer Query}` ? Q
|
|
|
15
14
|
/**
|
|
16
15
|
* Normalizes URL to path only, stripping protocol, host, port, and query
|
|
17
16
|
*/
|
|
18
|
-
type NormalizePath<T extends string> = StripQuery<
|
|
17
|
+
type NormalizePath<T extends string> = StripQuery<StripUrlOrigin<T>>;
|
|
19
18
|
/**
|
|
20
19
|
* Removes optional suffix from param name (e.g., ":id?" -> "id", ":id" -> "id")
|
|
21
20
|
*/
|
|
@@ -68,6 +67,5 @@ export type RequirePathParams<Path extends string, T> = [
|
|
|
68
67
|
* // => '/users/123/posts/456'
|
|
69
68
|
* ```
|
|
70
69
|
*/
|
|
71
|
-
export declare function generatePath<Path extends string>(path: AssertSupportedPath<Path>): string;
|
|
72
|
-
export declare function generatePath<Path extends string>(path: AssertSupportedPath<Path>, params: PathParams<Path>): string;
|
|
70
|
+
export declare function generatePath<Path extends string>(path: AssertSupportedPath<Path>, ...args: HasRequiredParams<Path> extends true ? [params: PathParams<Path>] : HasPathParams<Path> extends true ? [params?: PathParams<Path>] : [params?: PathParams<Path>]): string;
|
|
73
71
|
export {};
|
package/docs/BEST_PRACTICES.md
CHANGED
|
@@ -5,6 +5,7 @@ This guide provides recommendations for using 1000fetches effectively in product
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
7
7
|
- [Client Configuration](#client-configuration)
|
|
8
|
+
- [Dynamic Defaults](#dynamic-defaults)
|
|
8
9
|
- [Error Handling](#error-handling)
|
|
9
10
|
- [Schema Validation](#schema-validation)
|
|
10
11
|
- [Middleware](#middleware)
|
|
@@ -68,12 +69,61 @@ const postClient = createHttpClient({ baseUrl: 'https://api.example.com' })
|
|
|
68
69
|
const apiClient = createHttpClient({ baseUrl: 'https://api.example.com' })
|
|
69
70
|
```
|
|
70
71
|
|
|
72
|
+
## Dynamic Defaults
|
|
73
|
+
|
|
74
|
+
### ✅ DO: Use Defaults for Dynamic Auth and Request-Shaped Defaults
|
|
75
|
+
|
|
76
|
+
Use `defaults` when a value should be resolved for each request but does not need to mutate the final request context.
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
const apiClient = createHttpClient({
|
|
80
|
+
baseUrl: 'https://api.example.com',
|
|
81
|
+
defaults: async ({ resolvedPath, method }) => {
|
|
82
|
+
const token = await getAuthToken()
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `Bearer ${token}`,
|
|
87
|
+
'X-Client': 'web',
|
|
88
|
+
},
|
|
89
|
+
timeout: resolvedPath.startsWith('/reports') ? 60_000 : 10_000,
|
|
90
|
+
retry: method === 'GET',
|
|
91
|
+
params: {
|
|
92
|
+
locale: 'en-US',
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### ✅ DO: Let Request Options Override Defaults
|
|
100
|
+
|
|
101
|
+
Request options are applied after dynamic defaults, so callers can opt out or specialize one request without creating another client.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
const apiClient = createHttpClient({
|
|
105
|
+
baseUrl: 'https://api.example.com',
|
|
106
|
+
defaults: () => ({
|
|
107
|
+
headers: { 'X-Client': 'web' },
|
|
108
|
+
timeout: 10_000,
|
|
109
|
+
retry: true,
|
|
110
|
+
}),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
await apiClient.get('/health', {
|
|
114
|
+
timeout: 2_000,
|
|
115
|
+
retry: false,
|
|
116
|
+
headers: { 'X-Client': 'health-check' },
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
71
120
|
## Error Handling
|
|
72
121
|
|
|
73
122
|
### ✅ DO: Handle Specific Error Types
|
|
74
123
|
|
|
75
124
|
```typescript
|
|
76
125
|
import {
|
|
126
|
+
AbortError,
|
|
77
127
|
HttpError,
|
|
78
128
|
NetworkError,
|
|
79
129
|
TimeoutError,
|
|
@@ -97,6 +147,8 @@ async function fetchUser(id: string) {
|
|
|
97
147
|
throw new ConnectivityError('Network connection failed')
|
|
98
148
|
} else if (error instanceof TimeoutError) {
|
|
99
149
|
throw new TimeoutError('Request timed out')
|
|
150
|
+
} else if (error instanceof AbortError) {
|
|
151
|
+
throw error
|
|
100
152
|
} else {
|
|
101
153
|
throw error
|
|
102
154
|
}
|
|
@@ -121,6 +173,27 @@ const newUser = await apiClient.post('/users', userData, {
|
|
|
121
173
|
})
|
|
122
174
|
```
|
|
123
175
|
|
|
176
|
+
### ✅ DO: Declare Valid Non-2xx Statuses Explicitly
|
|
177
|
+
|
|
178
|
+
The default success rule is any 2xx status. Use a response contract status map when the protocol intentionally uses another status, such as conditional requests that return `304 Not Modified`.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const response = await apiClient
|
|
182
|
+
.get('/reports/:id', {
|
|
183
|
+
pathParams: { id: reportId },
|
|
184
|
+
})
|
|
185
|
+
.contract({
|
|
186
|
+
success: {
|
|
187
|
+
default: ReportSchema,
|
|
188
|
+
304: z.undefined(),
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
if (response.status === 304) {
|
|
193
|
+
return cachedReport
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
124
197
|
## Schema Validation
|
|
125
198
|
|
|
126
199
|
### ✅ DO: Validate Response Data
|
|
@@ -137,7 +210,7 @@ const userSchema = z.object({
|
|
|
137
210
|
updatedAt: z.iso.datetime(),
|
|
138
211
|
})
|
|
139
212
|
|
|
140
|
-
await apiClient.get(`/users/${id}`).
|
|
213
|
+
await apiClient.get(`/users/${id}`).contract(userSchema)
|
|
141
214
|
```
|
|
142
215
|
|
|
143
216
|
### ✅ DO: Create Reusable Schema Compositions
|
|
@@ -163,24 +236,24 @@ const PostSchema = BaseEntitySchema.extend({
|
|
|
163
236
|
|
|
164
237
|
## Middleware
|
|
165
238
|
|
|
166
|
-
### ✅ DO: Use Middleware for
|
|
239
|
+
### ✅ DO: Use Middleware for Observation and Deliberate Mutation
|
|
167
240
|
|
|
168
241
|
```typescript
|
|
169
242
|
const apiClient = createHttpClient({
|
|
170
243
|
baseUrl: 'https://api.example.com',
|
|
171
|
-
onRequestMiddleware:
|
|
172
|
-
const token = await getAuthToken()
|
|
244
|
+
onRequestMiddleware: context => {
|
|
173
245
|
console.log(`→ ${context.method} ${context.url}`)
|
|
174
|
-
context.headers.set('Authorization', `Bearer ${token}`)
|
|
175
246
|
return context
|
|
176
247
|
},
|
|
177
|
-
onResponseMiddleware:
|
|
248
|
+
onResponseMiddleware: response => {
|
|
178
249
|
console.log(`← ${response.status} ${response.method} ${response.url}`)
|
|
179
250
|
return response
|
|
180
251
|
},
|
|
181
252
|
})
|
|
182
253
|
```
|
|
183
254
|
|
|
255
|
+
Middleware can observe requests and responses, and it can mutate request/response state. Keep it side-effect-light; use mutation only when dynamic defaults or request options are not expressive enough.
|
|
256
|
+
|
|
184
257
|
### ✅ DO: Handle Middleware Errors Gracefully
|
|
185
258
|
|
|
186
259
|
```typescript
|
|
@@ -188,12 +261,10 @@ const apiClient = createHttpClient({
|
|
|
188
261
|
baseUrl: 'https://api.example.com',
|
|
189
262
|
onRequestMiddleware: async context => {
|
|
190
263
|
try {
|
|
191
|
-
|
|
192
|
-
context.headers.set('Authorization', `Bearer ${token}`)
|
|
264
|
+
context.headers.set('X-Request-ID', await createRequestId())
|
|
193
265
|
return context
|
|
194
266
|
} catch (error) {
|
|
195
|
-
|
|
196
|
-
console.warn('Failed to get auth token:', error)
|
|
267
|
+
console.warn('Failed to create request id:', error)
|
|
197
268
|
return context
|
|
198
269
|
}
|
|
199
270
|
},
|
|
@@ -454,36 +525,43 @@ const apiClient = createHttpClient({
|
|
|
454
525
|
### ✅ DO: Add Request Tracking
|
|
455
526
|
|
|
456
527
|
```typescript
|
|
457
|
-
|
|
528
|
+
import type { ResponseType } from '1000fetches'
|
|
458
529
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
return context
|
|
465
|
-
},
|
|
466
|
-
onResponseMiddleware: async response => {
|
|
467
|
-
const requestId = response.headers['x-request-id']
|
|
468
|
-
const startTime = requestId ? requestTimings.get(requestId) : undefined
|
|
530
|
+
async function trackRequest<T>(
|
|
531
|
+
operation: string,
|
|
532
|
+
request: () => Promise<ResponseType<T>>
|
|
533
|
+
) {
|
|
534
|
+
const startTime = Date.now()
|
|
469
535
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
536
|
+
try {
|
|
537
|
+
const response = await request()
|
|
538
|
+
const duration = Date.now() - startTime
|
|
539
|
+
|
|
540
|
+
analytics.track('api_request', {
|
|
541
|
+
operation,
|
|
542
|
+
url: response.url,
|
|
543
|
+
method: response.method,
|
|
544
|
+
status: response.status,
|
|
545
|
+
duration,
|
|
546
|
+
})
|
|
473
547
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
548
|
+
return response
|
|
549
|
+
} catch (error) {
|
|
550
|
+
analytics.track('api_request_failed', {
|
|
551
|
+
operation,
|
|
552
|
+
duration: Date.now() - startTime,
|
|
553
|
+
})
|
|
480
554
|
|
|
481
|
-
|
|
482
|
-
|
|
555
|
+
throw error
|
|
556
|
+
}
|
|
557
|
+
}
|
|
483
558
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
559
|
+
const response = await trackRequest('fetch_user', () =>
|
|
560
|
+
apiClient.get('/users/:id', {
|
|
561
|
+
pathParams: { id: userId },
|
|
562
|
+
headers: { 'X-Request-ID': generateRequestId() },
|
|
563
|
+
})
|
|
564
|
+
)
|
|
487
565
|
```
|
|
488
566
|
|
|
489
567
|
### ✅ DO: Monitor Error Rates
|
|
@@ -577,8 +655,8 @@ try {
|
|
|
577
655
|
// Bad: Synchronous operations in middleware
|
|
578
656
|
const blockingClient = createHttpClient({
|
|
579
657
|
onRequestMiddleware: context => {
|
|
580
|
-
const
|
|
581
|
-
context.headers.set('
|
|
658
|
+
const metadata = fs.readFileSync('/path/to/metadata', 'utf8') // Blocks event loop
|
|
659
|
+
context.headers.set('X-Metadata', metadata)
|
|
582
660
|
return context
|
|
583
661
|
},
|
|
584
662
|
})
|
|
@@ -586,8 +664,8 @@ const blockingClient = createHttpClient({
|
|
|
586
664
|
// Good: Asynchronous operations
|
|
587
665
|
const asyncClient = createHttpClient({
|
|
588
666
|
onRequestMiddleware: async context => {
|
|
589
|
-
const
|
|
590
|
-
context.headers.set('
|
|
667
|
+
const metadata = await fs.promises.readFile('/path/to/metadata', 'utf8')
|
|
668
|
+
context.headers.set('X-Metadata', metadata)
|
|
591
669
|
return context
|
|
592
670
|
},
|
|
593
671
|
})
|
|
@@ -598,9 +676,10 @@ const asyncClient = createHttpClient({
|
|
|
598
676
|
Following these best practices will help you build robust, maintainable, and performant applications with 1000fetches:
|
|
599
677
|
|
|
600
678
|
1. **Configure once, use everywhere** - Create a single, well-configured client instance
|
|
601
|
-
2. **
|
|
602
|
-
3. **
|
|
603
|
-
4. **
|
|
604
|
-
5. **
|
|
605
|
-
6. **
|
|
606
|
-
7. **
|
|
679
|
+
2. **Use dynamic defaults** - Resolve auth and request-shaped defaults per request
|
|
680
|
+
3. **Handle errors explicitly** - Don't let errors fail silently
|
|
681
|
+
4. **Validate data** - Use schemas to ensure data integrity
|
|
682
|
+
5. **Keep middleware lightweight** - Avoid heavy operations that slow down requests
|
|
683
|
+
6. **Test thoroughly** - Mock HTTP calls and test error scenarios
|
|
684
|
+
7. **Monitor in production** - Track request metrics and error rates
|
|
685
|
+
8. **Secure by default** - Validate input and handle sensitive data carefully
|