1/**
2 * Typed failure model for Effect HTTP client operations.
3 *
4 * HTTP clients wrap request construction, transport, status-code validation, and
5 * response decoding failures in `HttpClientError`. The wrapper keeps the failed
6 * request and the specific failure reason together, so callers can handle client
7 * failures uniformly while still matching on the reason `_tag` for retry
8 * policy, logging, metrics, and user-facing messages.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13import * as Data from "../Data.ts"
14import { hasProperty } from "../Predicate.ts"
15import * as Schema from "../Schema.ts"
16import type * as HttpClientRequest from "./HttpClientRequest.ts"
17import type * as ClientResponse from "./HttpClientResponse.ts"
18
19const TypeId = "~effect/http/HttpClientError"
20
21/**
22 * Returns `true` when a value is an `HttpClientError`.
23 *
24 * @stability unstable
25 * @category guards
26 * @since 4.0.0
27 */
28export const isHttpClientError = (u: unknown): u is HttpClientError => hasProperty(u, TypeId)
29
30/**
31 * Error wrapper for HTTP client failures, exposing the failed request and the optional response through its `reason`.
32 *
33 * @stability unstable
34 * @category errors
35 * @since 4.0.0
36 */
37export class HttpClientError extends Data.TaggedError("HttpClientError")<{
38 readonly reason: HttpClientErrorReason
39}> {
40 constructor(props: {
41 readonly reason: HttpClientErrorReason
42 }) {
43 if ("cause" in props.reason) {
44 super({
45 ...props,
46 cause: props.reason.cause
47 } as any)
48 } else {
49 super(props)
50 }
51 }
52
53 /**
54 * Marks this value as an HTTP client error for runtime guards.
55 *
56 * @stability unstable
57 * @since 4.0.0
58 */
59 readonly [TypeId] = TypeId
60
61 /**
62 * HTTP request associated with the client failure.
63 *
64 * @stability unstable
65 * @since 4.0.0
66 */
67 get request(): HttpClientRequest.HttpClientRequest {
68 return this.reason.request
69 }
70
71 /**
72 * HTTP response associated with the client failure, when one was received.
73 *
74 * @stability unstable
75 * @since 4.0.0
76 */
77 get response(): ClientResponse.HttpClientResponse | undefined {
78 return "response" in this.reason ? this.reason.response : undefined
79 }
80
81 override get message(): string {
82 return this.reason.message
83 }
84}
85
86const formatReason = (tag: string) => tag.endsWith("Error") ? tag.slice(0, -5) : tag
87
88const formatMessage = (reason: string, description: string | undefined, info: string) =>
89 description ? `${reason}: ${description} (${info})` : `${reason} error (${info})`
90
91/**
92 * Error describing transport-level failures that occur while sending an HTTP request.
93 *
94 * @stability unstable
95 * @category errors
96 * @since 4.0.0
97 */
98export class TransportError extends Data.TaggedError("TransportError")<{
99 readonly request: HttpClientRequest.HttpClientRequest
100 readonly cause?: unknown
101 readonly description?: string
102}> {
103 /**
104 * Formats the request method and URL for transport error messages.
105 *
106 * @stability unstable
107 * @since 4.0.0
108 */
109 get methodAndUrl() {
110 return `${this.request.method} ${this.request.url}`
111 }
112
113 /**
114 * Builds the transport error message from the optional description and request details.
115 *
116 * @stability unstable
117 * @since 4.0.0
118 */
119 override get message() {
120 return formatMessage(formatReason(this._tag), this.description, this.methodAndUrl)
121 }
122}
123
124/**
125 * Error describing failures while encoding an HTTP request body.
126 *
127 * @stability unstable
128 * @category errors
129 * @since 4.0.0
130 */
131export class EncodeError extends Data.TaggedError("EncodeError")<{
132 readonly request: HttpClientRequest.HttpClientRequest
133 readonly cause?: unknown
134 readonly description?: string
135}> {
136 /**
137 * Formats the request method and URL for request encoding error messages.
138 *
139 * @stability unstable
140 * @since 4.0.0
141 */
142 get methodAndUrl() {
143 return `${this.request.method} ${this.request.url}`
144 }
145
146 /**
147 * Builds the request encoding error message from the optional description and request details.
148 *
149 * @stability unstable
150 * @since 4.0.0
151 */
152 override get message() {
153 return formatMessage(formatReason(this._tag), this.description, this.methodAndUrl)
154 }
155}
156
157/**
158 * Error describing failures while constructing a URL from an HTTP client request.
159 *
160 * @stability unstable
161 * @category errors
162 * @since 4.0.0
163 */
164export class InvalidUrlError extends Data.TaggedError("InvalidUrlError")<{
165 readonly request: HttpClientRequest.HttpClientRequest
166 readonly cause?: unknown
167 readonly description?: string
168}> {
169 /**
170 * Formats the request method and URL for invalid URL error messages.
171 *
172 * @stability unstable
173 * @since 4.0.0
174 */
175 get methodAndUrl() {
176 return `${this.request.method} ${this.request.url}`
177 }
178
179 /**
180 * Builds the invalid URL error message from the optional description and request details.
181 *
182 * @stability unstable
183 * @since 4.0.0
184 */
185 override get message() {
186 return formatMessage(formatReason(this._tag), this.description, this.methodAndUrl)
187 }
188}
189
190/**
191 * Response error for HTTP responses rejected because of their status code.
192 *
193 * @stability unstable
194 * @category errors
195 * @since 4.0.0
196 */
197export class StatusCodeError extends Data.TaggedError("StatusCodeError")<{
198 readonly request: HttpClientRequest.HttpClientRequest
199 readonly response: ClientResponse.HttpClientResponse
200 readonly cause?: unknown
201 readonly description?: string | undefined
202}> {
203 /**
204 * Formats the request method and URL for status code error messages.
205 *
206 * @stability unstable
207 * @since 4.0.0
208 */
209 get methodAndUrl() {
210 return `${this.request.method} ${this.request.url}`
211 }
212
213 /**
214 * Builds the status code error message from the response status, optional description, and request details.
215 *
216 * @stability unstable
217 * @since 4.0.0
218 */
219 override get message() {
220 const info = `${this.response.status} ${this.methodAndUrl}`
221 return formatMessage(formatReason(this._tag), this.description, info)
222 }
223}
224
225/**
226 * Response error for failures while decoding an HTTP response body.
227 *
228 * @stability unstable
229 * @category errors
230 * @since 4.0.0
231 */
232export class DecodeError extends Data.TaggedError("DecodeError")<{
233 readonly request: HttpClientRequest.HttpClientRequest
234 readonly response: ClientResponse.HttpClientResponse
235 readonly cause?: unknown
236 readonly description?: string | undefined
237}> {
238 /**
239 * Formats the request method and URL for response decoding error messages.
240 *
241 * @stability unstable
242 * @since 4.0.0
243 */
244 get methodAndUrl() {
245 return `${this.request.method} ${this.request.url}`
246 }
247
248 /**
249 * Builds the response decoding error message from the response status, optional description, and request details.
250 *
251 * @stability unstable
252 * @since 4.0.0
253 */
254 override get message() {
255 const info = `${this.response.status} ${this.methodAndUrl}`
256 return formatMessage(formatReason(this._tag), this.description, info)
257 }
258}
259
260/**
261 * Response error for operations that expected a response body but received an empty body.
262 *
263 * @stability unstable
264 * @category errors
265 * @since 4.0.0
266 */
267export class EmptyBodyError extends Data.TaggedError("EmptyBodyError")<{
268 readonly request: HttpClientRequest.HttpClientRequest
269 readonly response: ClientResponse.HttpClientResponse
270 readonly cause?: unknown
271 readonly description?: string | undefined
272}> {
273 /**
274 * Formats the request method and URL for empty response body error messages.
275 *
276 * @stability unstable
277 * @since 4.0.0
278 */
279 get methodAndUrl() {
280 return `${this.request.method} ${this.request.url}`
281 }
282
283 /**
284 * Builds the empty body error message from the response status, optional description, and request details.
285 *
286 * @stability unstable
287 * @since 4.0.0
288 */
289 override get message() {
290 const info = `${this.response.status} ${this.methodAndUrl}`
291 return formatMessage(formatReason(this._tag), this.description, info)
292 }
293}
294
295/**
296 * Union of HTTP client errors that occur before a response is available.
297 *
298 * @stability unstable
299 * @category errors
300 * @since 4.0.0
301 */
302export type RequestError = TransportError | EncodeError | InvalidUrlError
303
304/**
305 * Union of HTTP client errors that include an HTTP response.
306 *
307 * @stability unstable
308 * @category errors
309 * @since 4.0.0
310 */
311export type ResponseError = StatusCodeError | DecodeError | EmptyBodyError
312
313/**
314 * Union of all specific failure reasons carried by `HttpClientError`.
315 *
316 * @stability unstable
317 * @category errors
318 * @since 4.0.0
319 */
320export type HttpClientErrorReason = RequestError | ResponseError
321
322/**
323 * Schema for serializable HTTP client errors, preserving the specific error kind
324 * and cause.
325 *
326 * @stability unstable
327 * @category schemas
328 * @since 4.0.0
329 */
330export class HttpClientErrorSchema extends Schema.Error<HttpClientErrorSchema>(TypeId)({
331 _tag: Schema.tag("HttpError"),
332 kind: Schema.Literals(
333 [
334 "EncodeError",
335 "DecodeError",
336 "TransportError",
337 "InvalidUrlError",
338 "StatusCodeError",
339 "EmptyBodyError"
340 ] satisfies ReadonlyArray<HttpClientErrorReason["_tag"]>
341 ),
342 cause: Schema.optional(Schema.Defect())
343}) {
344 /**
345 * Builds the serializable schema representation for an HTTP client error.
346 *
347 * @stability unstable
348 * @since 4.0.0
349 */
350 static fromHttpClientError(error: HttpClientError): HttpClientErrorSchema {
351 return new HttpClientErrorSchema({
352 _tag: "HttpError",
353 kind: error.reason._tag,
354 cause: error.reason
355 })
356 }
357}