@npy/fetch 0.1.1 → 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 (140) 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 +36 -25
  8. package/src/_internal/consts.ts +3 -0
  9. package/src/_internal/decode-stream-error.ts +16 -0
  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/src/types/agent.ts +98 -0
  33. package/src/types/{dialer.d.cts → dialer.ts} +22 -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.js +0 -4
  47. package/_internal/error-adapters.cjs +0 -146
  48. package/_internal/error-adapters.js +0 -142
  49. package/_internal/guards.cjs +0 -24
  50. package/_internal/guards.js +0 -17
  51. package/_internal/net.cjs +0 -95
  52. package/_internal/net.js +0 -92
  53. package/_internal/promises.cjs +0 -18
  54. package/_internal/promises.js +0 -18
  55. package/_internal/streams.cjs +0 -37
  56. package/_internal/streams.js +0 -36
  57. package/_virtual/_rolldown/runtime.cjs +0 -23
  58. package/agent-pool.cjs +0 -78
  59. package/agent-pool.js +0 -77
  60. package/agent.cjs +0 -257
  61. package/agent.js +0 -256
  62. package/body.cjs +0 -154
  63. package/body.js +0 -151
  64. package/dialers/proxy.cjs +0 -49
  65. package/dialers/proxy.js +0 -48
  66. package/dialers/tcp.cjs +0 -70
  67. package/dialers/tcp.js +0 -67
  68. package/encoding.cjs +0 -95
  69. package/encoding.js +0 -91
  70. package/errors.cjs +0 -275
  71. package/errors.js +0 -259
  72. package/fetch.cjs +0 -117
  73. package/fetch.js +0 -115
  74. package/http-client.cjs +0 -33
  75. package/http-client.js +0 -33
  76. package/index.cjs +0 -45
  77. package/index.d.cts +0 -1
  78. package/index.d.ts +0 -1
  79. package/index.js +0 -9
  80. package/io/_utils.cjs +0 -56
  81. package/io/_utils.js +0 -51
  82. package/io/buf-writer.cjs +0 -149
  83. package/io/buf-writer.js +0 -148
  84. package/io/io.cjs +0 -135
  85. package/io/io.js +0 -134
  86. package/io/readers.cjs +0 -377
  87. package/io/readers.js +0 -373
  88. package/io/writers.cjs +0 -191
  89. package/io/writers.js +0 -190
  90. package/src/_internal/consts.d.cts +0 -3
  91. package/src/_internal/consts.d.ts +0 -3
  92. package/src/_internal/error-adapters.d.cts +0 -22
  93. package/src/_internal/error-adapters.d.ts +0 -22
  94. package/src/_internal/guards.d.cts +0 -13
  95. package/src/_internal/guards.d.ts +0 -13
  96. package/src/_internal/net.d.cts +0 -12
  97. package/src/_internal/net.d.ts +0 -12
  98. package/src/_internal/promises.d.cts +0 -1
  99. package/src/_internal/promises.d.ts +0 -1
  100. package/src/_internal/streams.d.cts +0 -21
  101. package/src/_internal/streams.d.ts +0 -21
  102. package/src/agent-pool.d.cts +0 -2
  103. package/src/agent-pool.d.ts +0 -2
  104. package/src/agent.d.cts +0 -3
  105. package/src/agent.d.ts +0 -3
  106. package/src/body.d.cts +0 -23
  107. package/src/body.d.ts +0 -23
  108. package/src/dialers/index.d.cts +0 -3
  109. package/src/dialers/index.d.ts +0 -3
  110. package/src/dialers/proxy.d.cts +0 -19
  111. package/src/dialers/proxy.d.ts +0 -19
  112. package/src/dialers/tcp.d.cts +0 -36
  113. package/src/dialers/tcp.d.ts +0 -36
  114. package/src/encoding.d.cts +0 -24
  115. package/src/encoding.d.ts +0 -24
  116. package/src/errors.d.cts +0 -110
  117. package/src/errors.d.ts +0 -110
  118. package/src/fetch.d.cts +0 -36
  119. package/src/fetch.d.ts +0 -36
  120. package/src/http-client.d.cts +0 -23
  121. package/src/http-client.d.ts +0 -23
  122. package/src/index.d.cts +0 -7
  123. package/src/index.d.ts +0 -7
  124. package/src/io/_utils.d.cts +0 -10
  125. package/src/io/_utils.d.ts +0 -10
  126. package/src/io/buf-writer.d.cts +0 -13
  127. package/src/io/buf-writer.d.ts +0 -13
  128. package/src/io/io.d.cts +0 -5
  129. package/src/io/io.d.ts +0 -5
  130. package/src/io/readers.d.cts +0 -199
  131. package/src/io/readers.d.ts +0 -199
  132. package/src/io/writers.d.cts +0 -22
  133. package/src/io/writers.d.ts +0 -22
  134. package/src/types/agent.d.cts +0 -128
  135. package/src/types/agent.d.ts +0 -128
  136. package/src/types/dialer.d.ts +0 -27
  137. package/src/types/index.d.cts +0 -2
  138. package/src/types/index.d.ts +0 -2
  139. package/tests/test-utils.d.cts +0 -8
  140. package/tests/test-utils.d.ts +0 -8
@@ -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
+ });