@twinedo/app-error 1.0.0 → 1.0.1

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 CHANGED
@@ -1,8 +1,51 @@
1
1
  # @twinedo/app-error
2
2
 
3
- A small, dependency-free error normalization layer that turns any thrown error
4
- (fetch, axios-like, or runtime) into a predictable `AppError` shape. Use it to
5
- standardize UI messaging, retry logic, and logging across mixed stacks.
3
+ Framework-agnostic error normalization for fetch, axios-like clients, and runtime failures.
4
+
5
+ ## Why this exists
6
+
7
+ Most teams talk to more than one backend.
8
+ Each backend returns a different error shape (Project A vs Project B).
9
+ fetch and axios surface errors in different ways.
10
+ So every project rewrites the same parsing, message, and retry rules.
11
+ This library produces one predictable AppError for UI, logging, and retries.
12
+
13
+ ## Features
14
+
15
+ - Predictable `AppError` shape for UI, logging, and retry logic
16
+ - Works with `fetch` responses and axios-like errors
17
+ - Configurable per backend via `defineErrorPolicy`
18
+ - Framework-agnostic (React, React Native, Vue, Angular, Node)
19
+ - TypeScript-first with exported types
20
+ - Defensive normalization that never throws
21
+ - Retry decision helpers like `isRetryable`
22
+ - Zero dependencies
23
+
24
+ ## Guarantees & Non-Goals
25
+
26
+ ### Guarantees
27
+ This library guarantees that:
28
+
29
+ - Every normalization function (`toAppError`, `fromFetch`, `fromFetchResponse`) **never throws**
30
+ - You always receive a **predictable `AppError` shape**
31
+ - `message` is always safe to display in UI
32
+ - Original errors are preserved via `cause` for debugging
33
+ - No input error is mutated
34
+ - Behavior is deterministic and side-effect free
35
+ - Fully TypeScript-friendly with stable public types
36
+
37
+ ### Non-Goals
38
+ This library intentionally does NOT:
39
+
40
+ - Automatically guess backend-specific error schemas
41
+ - Perform logging, reporting, or analytics
42
+ - Display UI or toast notifications
43
+ - Enforce localization or translations
44
+ - Replace HTTP clients like fetch or axios
45
+ - Hide errors or swallow failures silently
46
+
47
+ If your project has backend-specific error formats, use
48
+ `defineErrorPolicy` to explicitly describe how errors should be interpreted.
6
49
 
7
50
  ## Install
8
51
 
@@ -10,119 +53,134 @@ standardize UI messaging, retry logic, and logging across mixed stacks.
10
53
  npm install @twinedo/app-error
11
54
  ```
12
55
 
13
- ## What problem this solves
56
+ Published as the scoped package `@twinedo/app-error`. Ships ESM + CJS builds
57
+ with TypeScript types and works in Node and browser runtimes.
14
58
 
15
- Different APIs and HTTP clients emit wildly different error shapes. This library
16
- normalizes them into a single `AppError` so your app can consistently:
17
- - show a safe UI message
18
- - decide whether to retry
19
- - log a stable fingerprint
59
+ ## Examples
20
60
 
21
- ## Axios example
61
+ ### Example 1 — Axios with try/catch
22
62
 
23
63
  ```ts
24
64
  import axios from "axios";
25
- import { toAppError } from "@twinedo/app-error";
65
+ import { defineErrorPolicy, isRetryable, toAppError } from "@twinedo/app-error";
66
+
67
+ const policy = defineErrorPolicy();
26
68
 
27
69
  try {
28
- await axios.get("/api/user");
29
- } catch (error) {
30
- const appError = toAppError(error);
31
- console.log(appError.kind, appError.status, appError.message);
70
+ const response = await axios.get<{ id: string; name: string }>("/api/user");
71
+ console.log("User:", response.data.name);
72
+ } catch (err) {
73
+ const appError = toAppError(err, policy);
74
+ console.error(appError.message);
75
+
76
+ if (isRetryable(appError)) {
77
+ // show a retry action or schedule a retry
78
+ }
32
79
  }
33
80
  ```
34
81
 
35
- ## Fetch example
82
+ ### Example 2 — Fetch handling non-OK responses
36
83
 
37
84
  ```ts
38
- import { fromFetch } from "@twinedo/app-error";
85
+ import { defineErrorPolicy, fromFetchResponse, toAppError } from "@twinedo/app-error";
86
+
87
+ const policy = defineErrorPolicy();
39
88
 
40
- const response = await fetch("/api/user");
41
- const body = await response.json().catch(() => undefined);
89
+ try {
90
+ const res = await fetch("/api/user");
91
+
92
+ if (!res.ok) {
93
+ throw await fromFetchResponse(res, policy);
94
+ }
42
95
 
43
- if (!response.ok) {
44
- throw fromFetch(response, body);
96
+ const data = await res.json();
97
+ console.log("User:", data);
98
+ } catch (err) {
99
+ const appError = toAppError(err, policy);
100
+ console.error(appError.message);
45
101
  }
46
102
  ```
47
103
 
48
- ## Project A vs Project B policy configuration
104
+ ### Example 3 — Project A vs Project B backend policies
49
105
 
50
106
  ```ts
51
- import { defineErrorPolicy } from "@twinedo/app-error";
107
+ import axios from "axios";
108
+ import { defineErrorPolicy, toAppError } from "@twinedo/app-error";
109
+
110
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
111
+ typeof value === "object" && value !== null;
52
112
 
113
+ const readString = (value: unknown): string | undefined =>
114
+ typeof value === "string" ? value : undefined;
115
+
116
+ const readHeader = (headers: unknown, name: string): string | undefined => {
117
+ if (!headers) return undefined;
118
+ const getter = (headers as { get?: (key: string) => string | null | undefined })
119
+ .get;
120
+ if (typeof getter === "function") {
121
+ return getter.call(headers, name) ?? undefined;
122
+ }
123
+ return readString((headers as Record<string, unknown>)[name]);
124
+ };
125
+
126
+ // Project A (Tony backend): { error: { message, code } }, header x-request-id
53
127
  const projectAPolicy = defineErrorPolicy({
54
128
  http: {
55
- message: (data) => (typeof data === "object" && data ? data.message : undefined),
56
- code: (data) => (typeof data === "object" && data ? data.code : undefined),
57
- requestId: (headers) => headers?.get?.("x-request-id") ?? undefined,
129
+ message: (data) =>
130
+ isRecord(data) && isRecord(data.error)
131
+ ? readString(data.error.message)
132
+ : undefined,
133
+ code: (data) =>
134
+ isRecord(data) && isRecord(data.error)
135
+ ? readString(data.error.code)
136
+ : undefined,
137
+ requestId: (headers) => readHeader(headers, "x-request-id"),
58
138
  },
59
139
  });
60
140
 
141
+ // Project B (Bobby backend): { message | msg, code }, header x-correlation-id
61
142
  const projectBPolicy = defineErrorPolicy({
62
143
  http: {
63
144
  message: (data) =>
64
- typeof data === "object" && data && "error" in data
65
- ? String((data as { error: unknown }).error)
66
- : undefined,
67
- code: (data) =>
68
- typeof data === "object" && data && "errorCode" in data
69
- ? String((data as { errorCode: unknown }).errorCode)
145
+ isRecord(data)
146
+ ? readString(data.message) ?? readString(data.msg)
70
147
  : undefined,
71
- requestId: (headers) => headers?.get?.("x-correlation-id") ?? undefined,
72
- retryable: (status) => status === 429 || (status ? status >= 500 : false),
148
+ code: (data) => (isRecord(data) ? readString(data.code) : undefined),
149
+ requestId: (headers) => readHeader(headers, "x-correlation-id"),
73
150
  },
74
151
  });
75
- ```
76
152
 
77
- ## React / React Native usage
78
-
79
- ```tsx
80
- import { useEffect, useState } from "react";
81
- import { fromFetch, isRetryable, toAppError } from "@twinedo/app-error";
82
-
83
- export function ProfileScreen() {
84
- const [error, setError] = useState<ReturnType<typeof toAppError> | null>(null);
85
-
86
- useEffect(() => {
87
- let mounted = true;
88
- fetch("/api/profile")
89
- .then(async (response) => {
90
- const body = await response.json().catch(() => undefined);
91
- if (!response.ok) throw fromFetch(response, body);
92
- return body;
93
- })
94
- .catch((err) => {
95
- if (!mounted) return;
96
- setError(toAppError(err));
97
- });
98
-
99
- return () => {
100
- mounted = false;
101
- };
102
- }, []);
103
-
104
- if (!error) return null;
105
-
106
- return (
107
- <>
108
- <Text>{error.message}</Text>
109
- {isRetryable(error) ? <Button title="Retry" /> : null}
110
- </>
111
- );
153
+ const handleError = (
154
+ err: unknown,
155
+ policy: ReturnType<typeof defineErrorPolicy>
156
+ ) => {
157
+ const appError = toAppError(err, policy);
158
+ console.error(appError.message, appError.code, appError.requestId);
159
+ };
160
+
161
+ try {
162
+ await axios.get("/api/user");
163
+ } catch (err) {
164
+ handleError(err, projectAPolicy);
165
+ }
166
+
167
+ try {
168
+ await axios.get("/api/user");
169
+ } catch (err) {
170
+ handleError(err, projectBPolicy);
112
171
  }
113
172
  ```
114
173
 
115
- ## Retry decision example
174
+ ### Example 4 — attempt() helper
116
175
 
117
176
  ```ts
118
- import { isRetryable, toAppError } from "@twinedo/app-error";
177
+ import { attempt } from "@twinedo/app-error";
119
178
 
120
- try {
121
- await apiCall();
122
- } catch (error) {
123
- const appError = toAppError(error);
124
- if (isRetryable(appError)) {
125
- // show retry action or auto-retry
126
- }
179
+ const result = await attempt(() => apiCall());
180
+
181
+ if (result.ok) {
182
+ console.log("Data:", result.data);
183
+ } else {
184
+ console.error(result.error.message);
127
185
  }
128
186
  ```
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  defineErrorPolicy: () => defineErrorPolicy,
25
25
  errorKey: () => errorKey,
26
26
  fromFetch: () => fromFetch,
27
+ fromFetchResponse: () => fromFetchResponse,
27
28
  isAppError: () => isAppError,
28
29
  isRetryable: () => isRetryable,
29
30
  toAppError: () => toAppError
@@ -237,6 +238,34 @@ var fromFetch = (response, body, policy) => {
237
238
  };
238
239
  };
239
240
 
241
+ // src/fromFetchResponse.ts
242
+ var readResponseBody = async (response) => {
243
+ const reader = response.text;
244
+ if (typeof reader !== "function") return void 0;
245
+ if (response.bodyUsed) return void 0;
246
+ let text;
247
+ try {
248
+ text = await reader.call(response);
249
+ } catch {
250
+ return void 0;
251
+ }
252
+ if (text.trim().length === 0) return void 0;
253
+ try {
254
+ return JSON.parse(text);
255
+ } catch {
256
+ return text;
257
+ }
258
+ };
259
+ var fromFetchResponse = async (response, policy) => {
260
+ const safeResponse = response ?? {};
261
+ try {
262
+ const body = await readResponseBody(safeResponse);
263
+ return fromFetch(safeResponse, body, policy);
264
+ } catch {
265
+ return fromFetch(safeResponse, void 0, policy);
266
+ }
267
+ };
268
+
240
269
  // src/adapters/axiosLike.ts
241
270
  var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ECONNABORTED", "ETIMEDOUT", "ESOCKETTIMEDOUT"]);
242
271
  var NETWORK_CODES = /* @__PURE__ */ new Set([
@@ -546,6 +575,7 @@ var attempt = async (fn, policy) => {
546
575
  defineErrorPolicy,
547
576
  errorKey,
548
577
  fromFetch,
578
+ fromFetchResponse,
549
579
  isAppError,
550
580
  isRetryable,
551
581
  toAppError
package/dist/index.d.cts CHANGED
@@ -41,6 +41,12 @@ type FetchResponseLike = {
41
41
  };
42
42
  declare const fromFetch: (response: FetchResponseLike, body?: unknown, policy?: ErrorPolicy) => AppError;
43
43
 
44
+ type FetchResponseWithBody = FetchResponseLike & {
45
+ text?: () => Promise<string>;
46
+ bodyUsed?: boolean;
47
+ };
48
+ declare const fromFetchResponse: (response: FetchResponseWithBody, policy?: ErrorPolicy) => Promise<AppError>;
49
+
44
50
  declare const toAppError: (error: unknown, policy?: ErrorPolicy) => AppError;
45
51
 
46
52
  type AttemptResult<T> = {
@@ -55,4 +61,4 @@ declare const errorKey: (error: AppError | unknown) => string;
55
61
  declare const isRetryable: (error: AppError | unknown) => boolean;
56
62
  declare const attempt: <T>(fn: () => T | Promise<T>, policy?: ErrorPolicy) => Promise<AttemptResult<T>>;
57
63
 
58
- export { type AppError, type AppErrorKind, type ErrorPolicy, type HeadersLike, type HttpPolicy, type HttpResponseLike, type NormalizedErrorPolicy, attempt, defineErrorPolicy, errorKey, fromFetch, isAppError, isRetryable, toAppError };
64
+ export { type AppError, type AppErrorKind, type ErrorPolicy, type HeadersLike, type HttpPolicy, type HttpResponseLike, type NormalizedErrorPolicy, attempt, defineErrorPolicy, errorKey, fromFetch, fromFetchResponse, isAppError, isRetryable, toAppError };
package/dist/index.d.ts CHANGED
@@ -41,6 +41,12 @@ type FetchResponseLike = {
41
41
  };
42
42
  declare const fromFetch: (response: FetchResponseLike, body?: unknown, policy?: ErrorPolicy) => AppError;
43
43
 
44
+ type FetchResponseWithBody = FetchResponseLike & {
45
+ text?: () => Promise<string>;
46
+ bodyUsed?: boolean;
47
+ };
48
+ declare const fromFetchResponse: (response: FetchResponseWithBody, policy?: ErrorPolicy) => Promise<AppError>;
49
+
44
50
  declare const toAppError: (error: unknown, policy?: ErrorPolicy) => AppError;
45
51
 
46
52
  type AttemptResult<T> = {
@@ -55,4 +61,4 @@ declare const errorKey: (error: AppError | unknown) => string;
55
61
  declare const isRetryable: (error: AppError | unknown) => boolean;
56
62
  declare const attempt: <T>(fn: () => T | Promise<T>, policy?: ErrorPolicy) => Promise<AttemptResult<T>>;
57
63
 
58
- export { type AppError, type AppErrorKind, type ErrorPolicy, type HeadersLike, type HttpPolicy, type HttpResponseLike, type NormalizedErrorPolicy, attempt, defineErrorPolicy, errorKey, fromFetch, isAppError, isRetryable, toAppError };
64
+ export { type AppError, type AppErrorKind, type ErrorPolicy, type HeadersLike, type HttpPolicy, type HttpResponseLike, type NormalizedErrorPolicy, attempt, defineErrorPolicy, errorKey, fromFetch, fromFetchResponse, isAppError, isRetryable, toAppError };
package/dist/index.js CHANGED
@@ -205,6 +205,34 @@ var fromFetch = (response, body, policy) => {
205
205
  };
206
206
  };
207
207
 
208
+ // src/fromFetchResponse.ts
209
+ var readResponseBody = async (response) => {
210
+ const reader = response.text;
211
+ if (typeof reader !== "function") return void 0;
212
+ if (response.bodyUsed) return void 0;
213
+ let text;
214
+ try {
215
+ text = await reader.call(response);
216
+ } catch {
217
+ return void 0;
218
+ }
219
+ if (text.trim().length === 0) return void 0;
220
+ try {
221
+ return JSON.parse(text);
222
+ } catch {
223
+ return text;
224
+ }
225
+ };
226
+ var fromFetchResponse = async (response, policy) => {
227
+ const safeResponse = response ?? {};
228
+ try {
229
+ const body = await readResponseBody(safeResponse);
230
+ return fromFetch(safeResponse, body, policy);
231
+ } catch {
232
+ return fromFetch(safeResponse, void 0, policy);
233
+ }
234
+ };
235
+
208
236
  // src/adapters/axiosLike.ts
209
237
  var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ECONNABORTED", "ETIMEDOUT", "ESOCKETTIMEDOUT"]);
210
238
  var NETWORK_CODES = /* @__PURE__ */ new Set([
@@ -513,6 +541,7 @@ export {
513
541
  defineErrorPolicy,
514
542
  errorKey,
515
543
  fromFetch,
544
+ fromFetchResponse,
516
545
  isAppError,
517
546
  isRetryable,
518
547
  toAppError
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinedo/app-error",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "A configurable error normalization layer for fetch, axios-like, and runtime errors.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
@@ -40,4 +40,4 @@
40
40
  "url": "https://github.com/twinedo/app-error/issues"
41
41
  },
42
42
  "homepage": "https://github.com/twinedo/app-error#readme"
43
- }
43
+ }
package/dist/index.mjs DELETED
@@ -1,510 +0,0 @@
1
- // src/policy.ts
2
- var DEFAULT_REQUEST_ID_HEADERS = [
3
- "x-request-id",
4
- "x-correlation-id",
5
- "x-trace-id",
6
- "traceparent",
7
- "x-amzn-trace-id"
8
- ];
9
- var isRecord = (value) => typeof value === "object" && value !== null;
10
- var normalizeString = (value) => {
11
- if (typeof value !== "string") return void 0;
12
- const trimmed = value.trim();
13
- return trimmed.length > 0 ? trimmed : void 0;
14
- };
15
- var normalizeHeaderValue = (value) => {
16
- if (typeof value === "number") return String(value);
17
- if (typeof value === "string") return value;
18
- return void 0;
19
- };
20
- var getHeaderValue = (headers, name) => {
21
- if (!headers) return void 0;
22
- const lowerName = name.toLowerCase();
23
- const getter = headers.get;
24
- if (typeof getter === "function") {
25
- const value = getter.call(headers, name) ?? getter.call(headers, lowerName);
26
- const normalized = normalizeString(value);
27
- if (normalized) return normalized;
28
- }
29
- if (isRecord(headers)) {
30
- for (const key of Object.keys(headers)) {
31
- if (key.toLowerCase() !== lowerName) continue;
32
- const raw = headers[key];
33
- if (Array.isArray(raw)) {
34
- const first = normalizeHeaderValue(raw[0]);
35
- const normalized = normalizeString(first);
36
- if (normalized) return normalized;
37
- } else {
38
- const normalized = normalizeString(normalizeHeaderValue(raw));
39
- if (normalized) return normalized;
40
- }
41
- }
42
- }
43
- return void 0;
44
- };
45
- var extractString = (value) => normalizeString(value);
46
- var extractFromArray = (value) => {
47
- for (const item of value) {
48
- const message = extractMessageFromData(item);
49
- if (message) return message;
50
- }
51
- return void 0;
52
- };
53
- var extractMessageFromData = (data) => {
54
- const direct = extractString(data);
55
- if (direct) return direct;
56
- if (Array.isArray(data)) return extractFromArray(data);
57
- if (!isRecord(data)) return void 0;
58
- const directKeys = ["message", "error", "detail", "title", "description"];
59
- for (const key of directKeys) {
60
- const value = extractString(data[key]);
61
- if (value) return value;
62
- }
63
- const errorValue = data.error;
64
- if (isRecord(errorValue)) {
65
- const nested = extractString(errorValue.message) ?? extractString(errorValue.detail);
66
- if (nested) return nested;
67
- }
68
- const errorsValue = data.errors;
69
- if (Array.isArray(errorsValue)) {
70
- const nested = extractFromArray(errorsValue);
71
- if (nested) return nested;
72
- }
73
- if (isRecord(errorsValue)) {
74
- for (const key of Object.keys(errorsValue)) {
75
- const fieldValue = errorsValue[key];
76
- if (Array.isArray(fieldValue)) {
77
- const nested = extractFromArray(fieldValue);
78
- if (nested) return nested;
79
- } else {
80
- const nested = extractString(fieldValue);
81
- if (nested) return nested;
82
- }
83
- }
84
- }
85
- return void 0;
86
- };
87
- var extractCodeFromData = (data) => {
88
- if (Array.isArray(data)) {
89
- for (const item of data) {
90
- const code = extractCodeFromData(item);
91
- if (code) return code;
92
- }
93
- return void 0;
94
- }
95
- if (!isRecord(data)) return void 0;
96
- const directKeys = ["code", "errorCode", "error_code"];
97
- for (const key of directKeys) {
98
- const value = extractString(data[key]);
99
- if (value) return value;
100
- }
101
- const errorValue = data.error;
102
- if (isRecord(errorValue)) {
103
- const nested = extractString(errorValue.code) ?? extractString(errorValue.errorCode);
104
- if (nested) return nested;
105
- }
106
- return void 0;
107
- };
108
- var defaultHttpMessage = (data, response) => {
109
- const fromData = extractMessageFromData(data);
110
- if (fromData) return fromData;
111
- return extractString(response?.statusText);
112
- };
113
- var defaultHttpCode = (data) => extractCodeFromData(data);
114
- var defaultRequestId = (headers) => {
115
- for (const header of DEFAULT_REQUEST_ID_HEADERS) {
116
- const value = getHeaderValue(headers, header);
117
- if (value) return value;
118
- }
119
- return void 0;
120
- };
121
- var defaultHttpRetryable = (status) => {
122
- if (typeof status !== "number") return false;
123
- return status >= 500 && status <= 599;
124
- };
125
- var DEFAULT_HTTP_POLICY = {
126
- message: defaultHttpMessage,
127
- code: defaultHttpCode,
128
- requestId: defaultRequestId,
129
- retryable: defaultHttpRetryable
130
- };
131
- var defineErrorPolicy = (...configs) => {
132
- const merged = {};
133
- for (const config of configs) {
134
- if (!config?.http) continue;
135
- Object.assign(merged, config.http);
136
- }
137
- return {
138
- http: {
139
- message: merged.message ?? DEFAULT_HTTP_POLICY.message,
140
- code: merged.code ?? DEFAULT_HTTP_POLICY.code,
141
- requestId: merged.requestId ?? DEFAULT_HTTP_POLICY.requestId,
142
- retryable: merged.retryable ?? DEFAULT_HTTP_POLICY.retryable
143
- }
144
- };
145
- };
146
-
147
- // src/types.ts
148
- var APP_ERROR_KINDS = {
149
- http: true,
150
- network: true,
151
- timeout: true,
152
- parse: true,
153
- validation: true,
154
- unknown: true
155
- };
156
- var isRecord2 = (value) => typeof value === "object" && value !== null;
157
- var isAppError = (value) => {
158
- if (!isRecord2(value)) return false;
159
- const kind = value.kind;
160
- if (typeof kind !== "string" || !(kind in APP_ERROR_KINDS)) return false;
161
- return typeof value.message === "string";
162
- };
163
-
164
- // src/fromFetch.ts
165
- var DEFAULT_MESSAGE = "Something went wrong";
166
- var normalizeMessage = (value) => {
167
- if (typeof value !== "string") return void 0;
168
- const trimmed = value.trim();
169
- return trimmed.length > 0 ? trimmed : void 0;
170
- };
171
- var defaultRetryable = (status) => {
172
- if (typeof status !== "number") return false;
173
- return status >= 500 && status <= 599;
174
- };
175
- var safeInvoke = (fn) => {
176
- try {
177
- return fn();
178
- } catch {
179
- return void 0;
180
- }
181
- };
182
- var fromFetch = (response, body, policy) => {
183
- const resolvedPolicy = defineErrorPolicy(policy);
184
- const status = typeof response.status === "number" ? response.status : void 0;
185
- const message = normalizeMessage(
186
- safeInvoke(() => resolvedPolicy.http.message(body, response))
187
- ) ?? DEFAULT_MESSAGE;
188
- const code = normalizeMessage(
189
- safeInvoke(() => resolvedPolicy.http.code(body, response))
190
- );
191
- const requestId = normalizeMessage(
192
- safeInvoke(() => resolvedPolicy.http.requestId(response.headers))
193
- );
194
- const retryable = safeInvoke(() => resolvedPolicy.http.retryable(status)) ?? defaultRetryable(status);
195
- return {
196
- kind: "http",
197
- message,
198
- retryable,
199
- ...status !== void 0 ? { status } : {},
200
- ...code ? { code } : {},
201
- ...requestId ? { requestId } : {},
202
- ...body !== void 0 ? { details: body } : {},
203
- ...response !== void 0 ? { cause: response } : {}
204
- };
205
- };
206
-
207
- // src/adapters/axiosLike.ts
208
- var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ECONNABORTED", "ETIMEDOUT", "ESOCKETTIMEDOUT"]);
209
- var NETWORK_CODES = /* @__PURE__ */ new Set([
210
- "ERR_NETWORK",
211
- "ENOTFOUND",
212
- "ECONNREFUSED",
213
- "ECONNRESET",
214
- "EAI_AGAIN",
215
- "ETIMEDOUT",
216
- "EHOSTUNREACH",
217
- "ENETUNREACH"
218
- ]);
219
- var isRecord3 = (value) => typeof value === "object" && value !== null;
220
- var getString = (value) => typeof value === "string" ? value : void 0;
221
- var getStatus = (value) => typeof value === "number" ? value : void 0;
222
- var getResponseLike = (value) => {
223
- if (!isRecord3(value)) return void 0;
224
- return {
225
- status: getStatus(value.status),
226
- statusText: getString(value.statusText),
227
- data: value.data,
228
- headers: value.headers
229
- };
230
- };
231
- var getAxiosLikeErrorInfo = (error) => {
232
- if (!isRecord3(error)) return null;
233
- const isAxiosMarker = error.isAxiosError === true;
234
- const response = getResponseLike(error.response);
235
- const request = error.request;
236
- const looksAxios = isAxiosMarker || response !== void 0 || request !== void 0;
237
- if (!looksAxios) return null;
238
- const code = getString(error.code);
239
- const message = getString(error.message);
240
- const status = response?.status;
241
- const data = response?.data;
242
- const headers = response?.headers;
243
- const messageLower = message?.toLowerCase();
244
- const isTimeout = (code ? TIMEOUT_CODES.has(code) : false) || (messageLower ? messageLower.includes("timeout") : false);
245
- const isNetworkError2 = !response && (request !== void 0 || (code ? NETWORK_CODES.has(code) : false) || (messageLower ? messageLower.includes("network error") : false));
246
- return {
247
- response,
248
- status,
249
- data,
250
- headers,
251
- code,
252
- message,
253
- isTimeout,
254
- isNetworkError: isNetworkError2
255
- };
256
- };
257
- var toHttpResponseLike = (info) => {
258
- if (!info.response) return void 0;
259
- return {
260
- status: info.status,
261
- statusText: info.response.statusText,
262
- headers: info.headers
263
- };
264
- };
265
-
266
- // src/toAppError.ts
267
- var DEFAULT_MESSAGE2 = "Something went wrong";
268
- var NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
269
- "ENOTFOUND",
270
- "ECONNREFUSED",
271
- "ECONNRESET",
272
- "EAI_AGAIN",
273
- "EHOSTUNREACH",
274
- "ENETUNREACH",
275
- "ERR_NETWORK"
276
- ]);
277
- var TIMEOUT_ERROR_CODES = /* @__PURE__ */ new Set([
278
- "ETIMEDOUT",
279
- "ESOCKETTIMEDOUT",
280
- "ECONNABORTED"
281
- ]);
282
- var isRecord4 = (value) => typeof value === "object" && value !== null;
283
- var getString2 = (value) => typeof value === "string" ? value : void 0;
284
- var normalizeMessage2 = (value) => {
285
- if (typeof value !== "string") return void 0;
286
- const trimmed = value.trim();
287
- return trimmed.length > 0 ? trimmed : void 0;
288
- };
289
- var defaultRetryable2 = (kind, status) => {
290
- if (kind === "network") return true;
291
- if (kind === "http" && typeof status === "number") {
292
- return status >= 500 && status <= 599;
293
- }
294
- return false;
295
- };
296
- var safeInvoke2 = (fn) => {
297
- try {
298
- return fn();
299
- } catch {
300
- return void 0;
301
- }
302
- };
303
- var getErrorInfo = (error) => {
304
- if (!isRecord4(error)) return { name: void 0, message: void 0, code: void 0 };
305
- return {
306
- name: getString2(error.name),
307
- message: getString2(error.message),
308
- code: getString2(error.code)
309
- };
310
- };
311
- var isTimeoutError = (name, message, code) => {
312
- if (name === "AbortError") return true;
313
- if (code && TIMEOUT_ERROR_CODES.has(code)) return true;
314
- const lowered = message?.toLowerCase();
315
- return lowered ? lowered.includes("timeout") : false;
316
- };
317
- var isNetworkError = (name, message, code) => {
318
- if (name === "TypeError") {
319
- const lowered2 = message?.toLowerCase() ?? "";
320
- if (lowered2.includes("failed to fetch") || lowered2.includes("network request failed") || lowered2.includes("networkerror") || lowered2.includes("load failed")) {
321
- return true;
322
- }
323
- }
324
- if (code && NETWORK_ERROR_CODES.has(code)) return true;
325
- const lowered = message?.toLowerCase() ?? "";
326
- return lowered.includes("network error");
327
- };
328
- var isParseError = (name) => name === "SyntaxError";
329
- var isValidationError = (error, name) => {
330
- if (name && name.toLowerCase().includes("validation")) return true;
331
- if (!isRecord4(error)) return false;
332
- return Array.isArray(error.issues) || Array.isArray(error.errors);
333
- };
334
- var normalizeExisting = (error) => {
335
- const message = normalizeMessage2(error.message) ?? DEFAULT_MESSAGE2;
336
- const retryable = typeof error.retryable === "boolean" ? error.retryable : defaultRetryable2(error.kind, error.status);
337
- return {
338
- ...error,
339
- message,
340
- retryable
341
- };
342
- };
343
- var buildAppError = (options) => ({
344
- kind: options.kind,
345
- message: options.message,
346
- retryable: options.retryable,
347
- ...options.status !== void 0 ? { status: options.status } : {},
348
- ...options.code ? { code: options.code } : {},
349
- ...options.requestId ? { requestId: options.requestId } : {},
350
- ...options.details !== void 0 ? { details: options.details } : {},
351
- ...options.cause !== void 0 ? { cause: options.cause } : {}
352
- });
353
- var fromStatusObject = (error, policy) => {
354
- if (!isRecord4(error)) return null;
355
- if (typeof error.status !== "number" || error.status < 400) return null;
356
- const response = {
357
- status: error.status,
358
- statusText: getString2(error.statusText),
359
- headers: error.headers
360
- };
361
- const details = error.data !== void 0 ? error.data : error.body !== void 0 ? error.body : error.details;
362
- const message = normalizeMessage2(safeInvoke2(() => policy.http.message(details, response))) ?? DEFAULT_MESSAGE2;
363
- const code = safeInvoke2(() => policy.http.code(details, response));
364
- const requestId = safeInvoke2(() => policy.http.requestId(response.headers));
365
- const retryable = safeInvoke2(() => policy.http.retryable(error.status)) ?? defaultRetryable2("http", error.status);
366
- return buildAppError({
367
- kind: "http",
368
- message,
369
- status: error.status,
370
- code: normalizeMessage2(code),
371
- retryable,
372
- requestId: normalizeMessage2(requestId),
373
- details,
374
- cause: error
375
- });
376
- };
377
- var toAppError = (error, policy) => {
378
- const resolvedPolicy = defineErrorPolicy(policy);
379
- try {
380
- if (isAppError(error)) return normalizeExisting(error);
381
- const axiosInfo = getAxiosLikeErrorInfo(error);
382
- if (axiosInfo) {
383
- if (axiosInfo.response) {
384
- const response = toHttpResponseLike(axiosInfo);
385
- const message2 = normalizeMessage2(
386
- safeInvoke2(() => resolvedPolicy.http.message(axiosInfo.data, response))
387
- ) ?? DEFAULT_MESSAGE2;
388
- const code2 = safeInvoke2(
389
- () => resolvedPolicy.http.code(axiosInfo.data, response)
390
- );
391
- const requestId = safeInvoke2(
392
- () => resolvedPolicy.http.requestId(axiosInfo.headers)
393
- );
394
- const retryable = safeInvoke2(() => resolvedPolicy.http.retryable(axiosInfo.status)) ?? defaultRetryable2("http", axiosInfo.status);
395
- return buildAppError({
396
- kind: "http",
397
- message: message2,
398
- status: axiosInfo.status,
399
- code: normalizeMessage2(code2),
400
- retryable,
401
- requestId: normalizeMessage2(requestId),
402
- details: axiosInfo.data,
403
- cause: error
404
- });
405
- }
406
- if (axiosInfo.isTimeout) {
407
- return buildAppError({
408
- kind: "timeout",
409
- message: DEFAULT_MESSAGE2,
410
- retryable: false,
411
- cause: error
412
- });
413
- }
414
- if (axiosInfo.isNetworkError) {
415
- return buildAppError({
416
- kind: "network",
417
- message: DEFAULT_MESSAGE2,
418
- retryable: true,
419
- cause: error
420
- });
421
- }
422
- }
423
- const { name, message, code } = getErrorInfo(error);
424
- if (isTimeoutError(name, message, code)) {
425
- return buildAppError({
426
- kind: "timeout",
427
- message: DEFAULT_MESSAGE2,
428
- retryable: false,
429
- cause: error
430
- });
431
- }
432
- if (isNetworkError(name, message, code)) {
433
- return buildAppError({
434
- kind: "network",
435
- message: DEFAULT_MESSAGE2,
436
- retryable: true,
437
- cause: error
438
- });
439
- }
440
- if (isParseError(name)) {
441
- return buildAppError({
442
- kind: "parse",
443
- message: DEFAULT_MESSAGE2,
444
- retryable: false,
445
- cause: error
446
- });
447
- }
448
- if (isValidationError(error, name)) {
449
- return buildAppError({
450
- kind: "validation",
451
- message: DEFAULT_MESSAGE2,
452
- retryable: false,
453
- cause: error,
454
- details: error
455
- });
456
- }
457
- const httpFromStatus = fromStatusObject(error, resolvedPolicy);
458
- if (httpFromStatus) return httpFromStatus;
459
- } catch {
460
- }
461
- return buildAppError({
462
- kind: "unknown",
463
- message: DEFAULT_MESSAGE2,
464
- retryable: false,
465
- cause: error
466
- });
467
- };
468
-
469
- // src/helpers.ts
470
- var normalizeMessage3 = (value) => {
471
- if (typeof value !== "string") return void 0;
472
- const trimmed = value.trim();
473
- return trimmed.length > 0 ? trimmed : void 0;
474
- };
475
- var errorKey = (error) => {
476
- const normalized = isAppError(error) ? error : toAppError(error);
477
- const parts = [
478
- normalized.kind,
479
- normalized.status !== void 0 ? String(normalized.status) : void 0,
480
- normalizeMessage3(normalized.code),
481
- normalizeMessage3(normalized.message)
482
- ].filter((value) => Boolean(value));
483
- return parts.join("|");
484
- };
485
- var isRetryable = (error) => {
486
- const normalized = isAppError(error) ? error : toAppError(error);
487
- if (typeof normalized.retryable === "boolean") return normalized.retryable;
488
- if (normalized.kind === "network") return true;
489
- if (normalized.kind === "http") {
490
- return typeof normalized.status === "number" && normalized.status >= 500;
491
- }
492
- return false;
493
- };
494
- var attempt = async (fn, policy) => {
495
- try {
496
- const data = await fn();
497
- return { ok: true, data };
498
- } catch (error) {
499
- return { ok: false, error: toAppError(error, policy) };
500
- }
501
- };
502
- export {
503
- attempt,
504
- defineErrorPolicy,
505
- errorKey,
506
- fromFetch,
507
- isAppError,
508
- isRetryable,
509
- toAppError
510
- };