@npy/fetch 0.1.2 → 0.1.3

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.
Files changed (142) hide show
  1. package/README.md +143 -50
  2. package/bun.lock +68 -0
  3. package/examples/custom-proxy-client.ts +32 -0
  4. package/examples/http-client.ts +47 -0
  5. package/examples/proxy.ts +16 -0
  6. package/examples/simple.ts +15 -0
  7. package/package.json +25 -30
  8. package/src/_internal/consts.ts +3 -0
  9. package/{_internal/decode-stream-error.d.cts → src/_internal/decode-stream-error.ts} +7 -2
  10. package/src/_internal/error-mapping.ts +160 -0
  11. package/src/_internal/guards.ts +78 -0
  12. package/src/_internal/net.ts +173 -0
  13. package/src/_internal/promises.ts +22 -0
  14. package/src/_internal/streams.ts +52 -0
  15. package/src/_internal/symbols.ts +1 -0
  16. package/src/agent-pool.ts +157 -0
  17. package/src/agent.ts +408 -0
  18. package/src/body.ts +179 -0
  19. package/src/dialers/index.ts +3 -0
  20. package/src/dialers/proxy.ts +102 -0
  21. package/src/dialers/tcp.ts +162 -0
  22. package/src/encoding.ts +222 -0
  23. package/src/errors.ts +357 -0
  24. package/src/fetch.ts +626 -0
  25. package/src/http-client.ts +111 -0
  26. package/src/index.ts +14 -0
  27. package/src/io/_utils.ts +82 -0
  28. package/src/io/buf-writer.ts +183 -0
  29. package/src/io/io.ts +322 -0
  30. package/src/io/readers.ts +576 -0
  31. package/src/io/writers.ts +331 -0
  32. package/{types/agent.d.cts → src/types/agent.ts} +47 -21
  33. package/{types/dialer.d.cts → src/types/dialer.ts} +19 -9
  34. package/src/types/index.ts +2 -0
  35. package/tests/agent-pool.test.ts +111 -0
  36. package/tests/agent.test.ts +134 -0
  37. package/tests/body.test.ts +228 -0
  38. package/tests/errors.test.ts +152 -0
  39. package/tests/fetch.test.ts +421 -0
  40. package/tests/io-options.test.ts +127 -0
  41. package/tests/multipart.test.ts +348 -0
  42. package/tests/test-utils.ts +335 -0
  43. package/tsconfig.json +15 -0
  44. package/LICENSE +0 -21
  45. package/_internal/consts.cjs +0 -4
  46. package/_internal/consts.d.cts +0 -3
  47. package/_internal/consts.d.ts +0 -3
  48. package/_internal/consts.js +0 -4
  49. package/_internal/decode-stream-error.cjs +0 -18
  50. package/_internal/decode-stream-error.d.ts +0 -11
  51. package/_internal/decode-stream-error.js +0 -18
  52. package/_internal/error-mapping.cjs +0 -44
  53. package/_internal/error-mapping.d.cts +0 -15
  54. package/_internal/error-mapping.d.ts +0 -15
  55. package/_internal/error-mapping.js +0 -41
  56. package/_internal/guards.cjs +0 -23
  57. package/_internal/guards.d.cts +0 -15
  58. package/_internal/guards.d.ts +0 -15
  59. package/_internal/guards.js +0 -15
  60. package/_internal/net.cjs +0 -95
  61. package/_internal/net.d.cts +0 -11
  62. package/_internal/net.d.ts +0 -11
  63. package/_internal/net.js +0 -92
  64. package/_internal/promises.cjs +0 -18
  65. package/_internal/promises.d.cts +0 -1
  66. package/_internal/promises.d.ts +0 -1
  67. package/_internal/promises.js +0 -18
  68. package/_internal/streams.cjs +0 -37
  69. package/_internal/streams.d.cts +0 -21
  70. package/_internal/streams.d.ts +0 -21
  71. package/_internal/streams.js +0 -36
  72. package/_internal/symbols.cjs +0 -4
  73. package/_internal/symbols.d.cts +0 -1
  74. package/_internal/symbols.d.ts +0 -1
  75. package/_internal/symbols.js +0 -4
  76. package/_virtual/_rolldown/runtime.cjs +0 -23
  77. package/agent-pool.cjs +0 -96
  78. package/agent-pool.d.cts +0 -2
  79. package/agent-pool.d.ts +0 -2
  80. package/agent-pool.js +0 -95
  81. package/agent.cjs +0 -260
  82. package/agent.d.cts +0 -3
  83. package/agent.d.ts +0 -3
  84. package/agent.js +0 -259
  85. package/body.cjs +0 -105
  86. package/body.d.cts +0 -12
  87. package/body.d.ts +0 -12
  88. package/body.js +0 -102
  89. package/dialers/index.d.cts +0 -3
  90. package/dialers/index.d.ts +0 -3
  91. package/dialers/proxy.cjs +0 -56
  92. package/dialers/proxy.d.cts +0 -27
  93. package/dialers/proxy.d.ts +0 -27
  94. package/dialers/proxy.js +0 -55
  95. package/dialers/tcp.cjs +0 -92
  96. package/dialers/tcp.d.cts +0 -57
  97. package/dialers/tcp.d.ts +0 -57
  98. package/dialers/tcp.js +0 -89
  99. package/encoding.cjs +0 -114
  100. package/encoding.d.cts +0 -35
  101. package/encoding.d.ts +0 -35
  102. package/encoding.js +0 -110
  103. package/errors.cjs +0 -275
  104. package/errors.d.cts +0 -110
  105. package/errors.d.ts +0 -110
  106. package/errors.js +0 -259
  107. package/fetch.cjs +0 -353
  108. package/fetch.d.cts +0 -58
  109. package/fetch.d.ts +0 -58
  110. package/fetch.js +0 -350
  111. package/http-client.cjs +0 -75
  112. package/http-client.d.cts +0 -39
  113. package/http-client.d.ts +0 -39
  114. package/http-client.js +0 -75
  115. package/index.cjs +0 -49
  116. package/index.d.cts +0 -14
  117. package/index.d.ts +0 -14
  118. package/index.js +0 -11
  119. package/io/_utils.cjs +0 -56
  120. package/io/_utils.d.cts +0 -10
  121. package/io/_utils.d.ts +0 -10
  122. package/io/_utils.js +0 -51
  123. package/io/buf-writer.cjs +0 -149
  124. package/io/buf-writer.d.cts +0 -13
  125. package/io/buf-writer.d.ts +0 -13
  126. package/io/buf-writer.js +0 -148
  127. package/io/io.cjs +0 -199
  128. package/io/io.d.cts +0 -5
  129. package/io/io.d.ts +0 -5
  130. package/io/io.js +0 -198
  131. package/io/readers.cjs +0 -337
  132. package/io/readers.d.cts +0 -69
  133. package/io/readers.d.ts +0 -69
  134. package/io/readers.js +0 -333
  135. package/io/writers.cjs +0 -196
  136. package/io/writers.d.cts +0 -22
  137. package/io/writers.d.ts +0 -22
  138. package/io/writers.js +0 -195
  139. package/types/agent.d.ts +0 -72
  140. package/types/dialer.d.ts +0 -30
  141. package/types/index.d.cts +0 -2
  142. package/types/index.d.ts +0 -2
@@ -0,0 +1,134 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { createAgent } from "../src/agent";
3
+ import { AutoDialer } from "../src/dialers";
4
+ import {
5
+ AgentBusyError,
6
+ OriginMismatchError,
7
+ RequestAbortedError,
8
+ ResponseDecodeError,
9
+ } from "../src/errors";
10
+ import { createTestServer } from "./test-utils";
11
+
12
+ describe("agent.ts", () => {
13
+ const testServer = createTestServer();
14
+ const dialer = new AutoDialer();
15
+
16
+ afterAll(async () => {
17
+ await testServer.stop();
18
+ });
19
+
20
+ test("agent performs sequential requests against the same origin", async () => {
21
+ const agent = createAgent(dialer, testServer.baseUrl);
22
+
23
+ try {
24
+ const response1 = await agent.send({
25
+ url: `${testServer.baseUrl}/text`,
26
+ method: "GET",
27
+ });
28
+ expect(response1.status).toBe(200);
29
+ expect(await response1.text()).toBe("Hello, World!");
30
+
31
+ const response2 = await agent.send({
32
+ url: `${testServer.baseUrl}/json`,
33
+ method: "GET",
34
+ });
35
+ expect(response2.status).toBe(200);
36
+ expect(await response2.json()).toEqual({
37
+ message: "Hello, JSON!",
38
+ });
39
+
40
+ expect(agent.isIdle).toBe(true);
41
+ } finally {
42
+ agent.close();
43
+ }
44
+ });
45
+
46
+ test("agent rejects cross-origin requests with OriginMismatchError", async () => {
47
+ const agent = createAgent(dialer, testServer.baseUrl);
48
+
49
+ try {
50
+ await expect(
51
+ agent.send({
52
+ url: "http://example.com/test",
53
+ method: "GET",
54
+ }),
55
+ ).rejects.toBeInstanceOf(OriginMismatchError);
56
+ } finally {
57
+ agent.close();
58
+ }
59
+ });
60
+
61
+ test("agent rejects concurrent use while busy with AgentBusyError", async () => {
62
+ const agent = createAgent(dialer, testServer.baseUrl);
63
+
64
+ try {
65
+ const slowRequest = agent.send({
66
+ url: `${testServer.baseUrl}/slow`,
67
+ method: "GET",
68
+ });
69
+
70
+ await expect(
71
+ agent.send({
72
+ url: `${testServer.baseUrl}/text`,
73
+ method: "GET",
74
+ }),
75
+ ).rejects.toBeInstanceOf(AgentBusyError);
76
+
77
+ const response = await slowRequest;
78
+ expect(await response.text()).toBe("Finally!");
79
+ expect(agent.isIdle).toBe(true);
80
+ } finally {
81
+ agent.close();
82
+ }
83
+ });
84
+
85
+ test("agent returns to idle after aborted requests", async () => {
86
+ const agent = createAgent(dialer, testServer.baseUrl);
87
+
88
+ try {
89
+ const controller = new AbortController();
90
+ const request = agent.send({
91
+ url: `${testServer.baseUrl}/slow`,
92
+ method: "GET",
93
+ signal: controller.signal,
94
+ });
95
+
96
+ setTimeout(() => controller.abort(new Error("abort test")), 50);
97
+
98
+ await expect(request).rejects.toBeInstanceOf(RequestAbortedError);
99
+ await expect(agent.whenIdle()).resolves.toBeUndefined();
100
+ expect(agent.isIdle).toBe(true);
101
+ } finally {
102
+ agent.close();
103
+ }
104
+ });
105
+
106
+ test("agent maps decoding failures during body consumption", async () => {
107
+ const agent = createAgent(dialer, testServer.baseUrl);
108
+
109
+ try {
110
+ const response = await agent.send({
111
+ url: `${testServer.baseUrl}/bad-gzip`,
112
+ method: "GET",
113
+ });
114
+
115
+ await expect(response.text()).rejects.toBeInstanceOf(
116
+ ResponseDecodeError,
117
+ );
118
+ } finally {
119
+ agent.close();
120
+ }
121
+ });
122
+
123
+ test("agent metadata reflects host and port", () => {
124
+ const agent = createAgent(dialer, testServer.baseUrl);
125
+
126
+ try {
127
+ const baseUrl = new URL(testServer.baseUrl);
128
+ expect(agent.hostname).toBe(baseUrl.hostname);
129
+ expect(agent.port).toBe(Number(baseUrl.port));
130
+ } finally {
131
+ agent.close();
132
+ }
133
+ });
134
+ });
@@ -0,0 +1,228 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Readable } from "node:stream";
3
+ import { extractBody, getFormDataLength } from "../src/body";
4
+
5
+ async function readAllBytes(readable: Readable): Promise<Uint8Array> {
6
+ const chunks: Buffer[] = [];
7
+ for await (const chunk of readable) {
8
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
9
+ }
10
+ return new Uint8Array(Buffer.concat(chunks));
11
+ }
12
+
13
+ function parseBoundary(contentType: string): string {
14
+ const match = /boundary=([^\s;]+)/.exec(contentType);
15
+ if (!match) throw new Error(`No boundary in: ${contentType}`);
16
+ return match[1]!;
17
+ }
18
+
19
+ describe("extractBody", () => {
20
+ test("null returns empty body with zero content-length", () => {
21
+ const state = extractBody(null);
22
+ expect(state.body).toBeNull();
23
+ expect(state.contentLength).toBe(0);
24
+ expect(state.contentType).toBeNull();
25
+ });
26
+
27
+ test("string sets UTF-8 content-type and correct byte length", () => {
28
+ const state = extractBody("héllo");
29
+ expect(state.body).toBeInstanceOf(Uint8Array);
30
+ expect(state.contentType).toBe("text/plain;charset=UTF-8");
31
+
32
+ expect(state.contentLength).toBe(6);
33
+ });
34
+
35
+ test("Uint8Array passes through with exact size", () => {
36
+ const bytes = new Uint8Array([10, 20, 30, 40]);
37
+ const state = extractBody(bytes);
38
+ expect(state.body).toBe(bytes);
39
+ expect(state.contentLength).toBe(4);
40
+ expect(state.contentType).toBeNull();
41
+ });
42
+
43
+ test("URLSearchParams sets form-urlencoded content-type", () => {
44
+ const params = new URLSearchParams({ a: "1", b: "2" });
45
+ const state = extractBody(params);
46
+ expect(state.contentType).toContain(
47
+ "application/x-www-form-urlencoded",
48
+ );
49
+ expect(state.body).toBeInstanceOf(Uint8Array);
50
+ const text = new TextDecoder().decode(state.body as Uint8Array);
51
+ expect(text).toBe("a=1&b=2");
52
+ });
53
+
54
+ test("FormData sets multipart content-type with boundary", () => {
55
+ const form = new FormData();
56
+ form.append("key", "value");
57
+
58
+ const state = extractBody(form);
59
+ expect(state.contentType).toContain("multipart/form-data");
60
+ expect(state.contentType).toContain("boundary=");
61
+ expect(state.contentLength).toBeGreaterThan(0);
62
+ expect(state.body).toBeInstanceOf(Readable);
63
+ });
64
+
65
+ test("empty FormData produces a valid (footer-only) body", () => {
66
+ const state = extractBody(new FormData());
67
+ expect(state.contentType).toContain("multipart/form-data");
68
+ expect(state.contentLength).toBeGreaterThan(0);
69
+
70
+ expect(state.body).toBeInstanceOf(Readable);
71
+ });
72
+
73
+ test("ReadableStream body is passed through unchanged", () => {
74
+ const stream = new ReadableStream();
75
+ const state = extractBody(stream);
76
+ expect(state.body).toBe(stream);
77
+ expect(state.contentLength).toBeNull();
78
+ });
79
+ });
80
+
81
+ describe("getFormDataLength", () => {
82
+ test("matches actual serialized byte count — text-only fields", async () => {
83
+ const form = new FormData();
84
+ form.append("username", "alice");
85
+ form.append("email", "alice@example.com");
86
+
87
+ const state = extractBody(form);
88
+ const boundary = parseBoundary(state.contentType!);
89
+ const declared = getFormDataLength(form, boundary);
90
+
91
+ const actual = await readAllBytes(state.body as Readable);
92
+ expect(actual.byteLength).toBe(declared);
93
+ });
94
+
95
+ test("matches actual serialized byte count — single Blob field", async () => {
96
+ const form = new FormData();
97
+ form.append(
98
+ "file",
99
+ new Blob(["binary content"], { type: "application/octet-stream" }),
100
+ "data.bin",
101
+ );
102
+
103
+ const state = extractBody(form);
104
+ const boundary = parseBoundary(state.contentType!);
105
+ const declared = getFormDataLength(form, boundary);
106
+
107
+ const actual = await readAllBytes(state.body as Readable);
108
+ expect(actual.byteLength).toBe(declared);
109
+ });
110
+
111
+ test("matches actual serialized byte count — mixed text and Blob fields", async () => {
112
+ const form = new FormData();
113
+ form.append("description", "a short text");
114
+ form.append(
115
+ "attachment",
116
+ new Blob(["<html></html>"], { type: "text/html" }),
117
+ "page.html",
118
+ );
119
+ form.append("tag", "important");
120
+
121
+ const state = extractBody(form);
122
+ const boundary = parseBoundary(state.contentType!);
123
+ const declared = getFormDataLength(form, boundary);
124
+
125
+ const actual = await readAllBytes(state.body as Readable);
126
+ expect(actual.byteLength).toBe(declared);
127
+ });
128
+
129
+ test("matches for empty FormData", async () => {
130
+ const form = new FormData();
131
+
132
+ const state = extractBody(form);
133
+ const boundary = parseBoundary(state.contentType!);
134
+ const declared = getFormDataLength(form, boundary);
135
+
136
+ const actual = await readAllBytes(state.body as Readable);
137
+ expect(actual.byteLength).toBe(declared);
138
+ });
139
+
140
+ test("matches for multibyte UTF-8 field value", async () => {
141
+ const form = new FormData();
142
+ form.append("greeting", "こんにちは");
143
+
144
+ const state = extractBody(form);
145
+ const boundary = parseBoundary(state.contentType!);
146
+ const declared = getFormDataLength(form, boundary);
147
+
148
+ const actual = await readAllBytes(state.body as Readable);
149
+ expect(actual.byteLength).toBe(declared);
150
+ });
151
+ });
152
+
153
+ describe("multipart wire format", () => {
154
+ test("text field produces valid multipart structure", async () => {
155
+ const form = new FormData();
156
+ form.append("name", "bob");
157
+
158
+ const state = extractBody(form);
159
+ const boundary = parseBoundary(state.contentType!);
160
+ const bytes = await readAllBytes(state.body as Readable);
161
+ const text = new TextDecoder().decode(bytes);
162
+
163
+ expect(text).toContain(`--${boundary}\r\n`);
164
+ expect(text).toContain(`Content-Disposition: form-data; name="name"`);
165
+ expect(text).toContain(`\r\n\r\nbob\r\n`);
166
+ expect(text).toContain(`--${boundary}--`);
167
+ });
168
+
169
+ test("Blob field includes filename and Content-Type headers", async () => {
170
+ const form = new FormData();
171
+ form.append(
172
+ "upload",
173
+ new Blob(["data"], { type: "text/plain" }),
174
+ "notes.txt",
175
+ );
176
+
177
+ const state = extractBody(form);
178
+ const bytes = await readAllBytes(state.body as Readable);
179
+ const text = new TextDecoder().decode(bytes);
180
+
181
+ expect(text).toContain(`filename="notes.txt"`);
182
+ expect(text).toContain(`Content-Type: text/plain`);
183
+ expect(text).toContain(`data`);
184
+ });
185
+
186
+ test("Blob without explicit filename falls back to 'blob'", async () => {
187
+ const form = new FormData();
188
+
189
+ form.append("f", new Blob(["x"]));
190
+
191
+ const state = extractBody(form);
192
+ const bytes = await readAllBytes(state.body as Readable);
193
+ const text = new TextDecoder().decode(bytes);
194
+
195
+ expect(text).toContain(`filename="blob"`);
196
+ });
197
+
198
+ test("Blob without type falls back to application/octet-stream", async () => {
199
+ const form = new FormData();
200
+ form.append("f", new Blob(["x"]), "file.dat");
201
+
202
+ const state = extractBody(form);
203
+ const bytes = await readAllBytes(state.body as Readable);
204
+ const text = new TextDecoder().decode(bytes);
205
+
206
+ expect(text).toContain(`Content-Type: application/octet-stream`);
207
+ });
208
+
209
+ test("each field is separated by boundary", async () => {
210
+ const form = new FormData();
211
+ form.append("a", "1");
212
+ form.append("b", "2");
213
+ form.append("c", "3");
214
+
215
+ const state = extractBody(form);
216
+ const boundary = parseBoundary(state.contentType!);
217
+ const bytes = await readAllBytes(state.body as Readable);
218
+ const text = new TextDecoder().decode(bytes);
219
+
220
+ const openings = (
221
+ text.match(new RegExp(`--${boundary}\r\n`, "g")) ?? []
222
+ ).length;
223
+ const closing = (text.match(new RegExp(`--${boundary}--`, "g")) ?? [])
224
+ .length;
225
+ expect(openings).toBe(3);
226
+ expect(closing).toBe(1);
227
+ });
228
+ });
@@ -0,0 +1,152 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ AgentBusyError,
4
+ ConnectionError,
5
+ ConnectTimeoutError,
6
+ ErrorType,
7
+ FetchError,
8
+ FetchErrorCode,
9
+ HttpStatusError,
10
+ OriginMismatchError,
11
+ RequestAbortedError,
12
+ ResponseBodyError,
13
+ ResponseHeaderError,
14
+ UnsupportedMethodError,
15
+ } from "../src/errors";
16
+
17
+ describe("errors.ts", () => {
18
+ test("base FetchError exposes code, phase, retryable and cause", () => {
19
+ const cause = new Error("boom");
20
+ const error = new ResponseHeaderError(cause, {
21
+ url: "http://example.test/resource",
22
+ method: "GET",
23
+ origin: "http://example.test",
24
+ });
25
+
26
+ expect(error).toBeInstanceOf(FetchError);
27
+ expect(error.name).toBe("ResponseHeaderError");
28
+ expect(error.code).toBe(FetchErrorCode.RESPONSE_HEADERS);
29
+ expect(error.phase).toBe("response");
30
+ expect(error.retryable).toBe(true);
31
+ expect(error.type).toBe(ErrorType.NETWORK);
32
+ expect(error.cause).toBe(cause);
33
+ expect(error.context?.url).toBe("http://example.test/resource");
34
+ expect(error.context?.method).toBe("GET");
35
+ });
36
+
37
+ test("abort/connect/network errors expose the expected classifications", () => {
38
+ const cause = new Error("boom");
39
+
40
+ const aborted = new RequestAbortedError(cause, {
41
+ url: "http://example.test/abort",
42
+ method: "GET",
43
+ });
44
+
45
+ const timeout = new ConnectTimeoutError(cause, {
46
+ host: "example.test",
47
+ port: 443,
48
+ });
49
+
50
+ const network = new ConnectionError(cause, {
51
+ host: "example.test",
52
+ port: 443,
53
+ });
54
+
55
+ expect(aborted.code).toBe(FetchErrorCode.ABORTED);
56
+ expect(aborted.phase).toBe("request");
57
+ expect(aborted.retryable).toBe(false);
58
+ expect(aborted.type).toBe(ErrorType.ABORTED);
59
+
60
+ expect(timeout.code).toBe(FetchErrorCode.TIMEOUT);
61
+ expect(timeout.phase).toBe("connect");
62
+ expect(timeout.retryable).toBe(true);
63
+ expect(timeout.type).toBe(ErrorType.TIMEOUT);
64
+
65
+ expect(network.code).toBe(FetchErrorCode.CONNECTION);
66
+ expect(network.phase).toBe("connect");
67
+ expect(network.retryable).toBe(true);
68
+ expect(network.type).toBe(ErrorType.NETWORK);
69
+ });
70
+
71
+ test("agent/policy errors expose specific metadata", () => {
72
+ const busy = new AgentBusyError({
73
+ origin: "http://example.test",
74
+ host: "example.test",
75
+ port: 80,
76
+ });
77
+
78
+ const mismatch = new OriginMismatchError(
79
+ "http://a.test",
80
+ "http://b.test",
81
+ { url: "http://b.test/resource" },
82
+ );
83
+
84
+ const unsupportedMethod = new UnsupportedMethodError("CONNECT", {
85
+ url: "http://example.test",
86
+ method: "CONNECT",
87
+ });
88
+
89
+ expect(busy.code).toBe(FetchErrorCode.AGENT_BUSY);
90
+ expect(busy.phase).toBe("agent");
91
+ expect(busy.retryable).toBe(true);
92
+
93
+ expect(mismatch.code).toBe(FetchErrorCode.ORIGIN_MISMATCH);
94
+ expect(mismatch.phase).toBe("policy");
95
+ expect(mismatch.retryable).toBe(false);
96
+ expect(mismatch.context?.details?.expectedOrigin).toBe("http://a.test");
97
+ expect(mismatch.context?.details?.actualOrigin).toBe("http://b.test");
98
+
99
+ expect(unsupportedMethod.code).toBe(FetchErrorCode.UNSUPPORTED_METHOD);
100
+ expect(unsupportedMethod.phase).toBe("policy");
101
+ expect(unsupportedMethod.retryable).toBe(false);
102
+ });
103
+
104
+ test("HTTP status errors classify 4xx and 5xx correctly", () => {
105
+ const http4xx = new HttpStatusError(404, {
106
+ url: "http://example.test/not-found",
107
+ method: "GET",
108
+ });
109
+
110
+ const http5xx = new HttpStatusError(503, {
111
+ url: "http://example.test/unavailable",
112
+ method: "GET",
113
+ });
114
+
115
+ expect(http4xx.code).toBe(FetchErrorCode.HTTP_STATUS);
116
+ expect(http4xx.phase).toBe("response");
117
+ expect(http4xx.retryable).toBe(false);
118
+ expect(http4xx.type).toBe(ErrorType.HTTP_CLIENT_ERROR);
119
+ expect(http4xx.statusCode).toBe(404);
120
+
121
+ expect(http5xx.code).toBe(FetchErrorCode.HTTP_STATUS);
122
+ expect(http5xx.phase).toBe("response");
123
+ expect(http5xx.retryable).toBe(true);
124
+ expect(http5xx.type).toBe(ErrorType.HTTP_SERVER_ERROR);
125
+ expect(http5xx.statusCode).toBe(503);
126
+ });
127
+
128
+ test("toJSON exposes the stable diagnostic payload", () => {
129
+ const cause = new Error("boom");
130
+ const error = new ResponseBodyError(cause, {
131
+ url: "http://example.test/stream",
132
+ method: "GET",
133
+ });
134
+
135
+ expect(error.toJSON()).toEqual({
136
+ name: "ResponseBodyError",
137
+ message: "Failed while reading response body",
138
+ code: FetchErrorCode.RESPONSE_BODY,
139
+ phase: "body",
140
+ retryable: true,
141
+ type: ErrorType.NETWORK,
142
+ context: {
143
+ url: "http://example.test/stream",
144
+ method: "GET",
145
+ },
146
+ cause: {
147
+ name: "Error",
148
+ message: "boom",
149
+ },
150
+ });
151
+ });
152
+ });