1/**
2 * Describes failures raised while handling HTTP server requests.
3 *
4 * `HttpServerError` covers failures that happen while accepting a request,
5 * matching a route, running a handler, or building and sending a response.
6 * Request-scoped failures keep the request that caused them, and response
7 * failures keep the response being produced. This module also includes helpers
8 * for turning failed causes or exits into HTTP responses and an annotation for
9 * interrupts caused by client aborts.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14import * as Cause from "../Cause.ts"
15import * as Context from "../Context.ts"
16import * as Data from "../Data.ts"
17import * as Effect from "../Effect.ts"
18import * as ErrorReporter from "../ErrorReporter.ts"
19import type * as Exit from "../Exit.ts"
20import { constUndefined } from "../Function.ts"
21import * as Option from "../Option.ts"
22import { hasProperty } from "../Predicate.ts"
23import type * as Request from "./HttpServerRequest.ts"
24import * as Respondable from "./HttpServerRespondable.ts"
25import * as Response from "./HttpServerResponse.ts"
26
27const TypeId = "~effect/http/HttpServerError"
28
29/**
30 * Tagged error for failures that occur while handling an HTTP server request.
31 *
32 * **Details**
33 *
34 * It wraps a `HttpServerErrorReason`, exposes the associated request and optional
35 * response, and can be converted to an HTTP response through the `Respondable`
36 * protocol.
37 *
38 * @stability unstable
39 * @category errors
40 * @since 4.0.0
41 */
42export class HttpServerError extends Data.TaggedError("HttpServerError")<{
43 readonly reason: HttpServerErrorReason
44}> implements Respondable.Respondable {
45 constructor(props: {
46 readonly reason: HttpServerErrorReason
47 }) {
48 if ("cause" in props.reason) {
49 super({
50 ...props,
51 cause: props.reason.cause
52 } as any)
53 } else {
54 super(props)
55 }
56 }
57
58 readonly [TypeId] = TypeId
59
60 override stack = `${this.name}: ${this.message}`
61
62 get request(): Request.HttpServerRequest {
63 return this.reason.request
64 }
65
66 get response(): Response.HttpServerResponse | undefined {
67 return "response" in this.reason ? this.reason.response : undefined
68 }
69
70 [Respondable.symbol]() {
71 return this.reason[Respondable.symbol]()
72 }
73
74 override get [ErrorReporter.ignore](): boolean {
75 return this.reason[ErrorReporter.ignore] ?? false
76 }
77
78 override get message(): string {
79 return this.reason.message
80 }
81}
82
83/**
84 * Error describing a failure to parse or read an incoming request.
85 *
86 * **Details**
87 *
88 * When converted to a response it produces an empty `400` response.
89 *
90 * @stability unstable
91 * @category errors
92 * @since 4.0.0
93 */
94export class RequestParseError extends Data.TaggedError("RequestParseError")<{
95 readonly request: Request.HttpServerRequest
96 readonly description?: string
97 readonly cause?: unknown
98}> implements Respondable.Respondable {
99 /**
100 * Converts the request error into a `400 Bad Request` response.
101 *
102 * @stability unstable
103 * @since 4.0.0
104 */
105 [Respondable.symbol]() {
106 return Effect.succeed(Response.empty({ status: 400 }))
107 }
108
109 get methodAndUrl() {
110 return `${this.request.method} ${this.request.url}`
111 }
112
113 override get message() {
114 return formatRequestMessage(this._tag, this.description, this.methodAndUrl)
115 }
116}
117
118/**
119 * Error indicating that no route matched the incoming request.
120 *
121 * **Details**
122 *
123 * When converted to a response it produces an empty `404` response, and it is
124 * ignored by the error reporter.
125 *
126 * @stability unstable
127 * @category errors
128 * @since 4.0.0
129 */
130export class RouteNotFound extends Data.TaggedError("RouteNotFound")<{
131 readonly request: Request.HttpServerRequest
132 readonly description?: string
133 readonly cause?: unknown
134}> implements Respondable.Respondable {
135 [Respondable.symbol]() {
136 return Effect.succeed(Response.empty({ status: 404 }))
137 }
138
139 override readonly [ErrorReporter.ignore] = true
140
141 get methodAndUrl() {
142 return `${this.request.method} ${this.request.url}`
143 }
144
145 override get message() {
146 return formatRequestMessage(this._tag, this.description, this.methodAndUrl)
147 }
148}
149
150/**
151 * Error describing an unexpected server-side failure while handling a request.
152 *
153 * **Details**
154 *
155 * When converted to a response it produces an empty `500` response.
156 *
157 * @stability unstable
158 * @category errors
159 * @since 4.0.0
160 */
161export class InternalError extends Data.TaggedError("InternalError")<{
162 readonly request: Request.HttpServerRequest
163 readonly description?: string
164 readonly cause?: unknown
165}> implements Respondable.Respondable {
166 /**
167 * Converts the server error into a `500 Internal Server Error` response.
168 *
169 * @stability unstable
170 * @since 4.0.0
171 */
172 [Respondable.symbol]() {
173 return Effect.succeed(Response.empty({ status: 500 }))
174 }
175
176 get methodAndUrl() {
177 return `${this.request.method} ${this.request.url}`
178 }
179
180 override get message() {
181 return formatRequestMessage(this._tag, this.description, this.methodAndUrl)
182 }
183}
184
185/**
186 * Returns `true` when the supplied value is an `HttpServerError`.
187 *
188 * @stability unstable
189 * @category guards
190 * @since 4.0.0
191 */
192export const isHttpServerError = (u: unknown): u is HttpServerError => hasProperty(u, TypeId)
193
194/**
195 * Error describing a failure related to an HTTP response.
196 *
197 * **Details**
198 *
199 * It carries the request and response involved in the failure. When converted to
200 * a response it produces an empty `500` response.
201 *
202 * @stability unstable
203 * @category errors
204 * @since 4.0.0
205 */
206export class ResponseError extends Data.TaggedError("ResponseError")<{
207 readonly request: Request.HttpServerRequest
208 readonly response: Response.HttpServerResponse
209 readonly description?: string
210 readonly cause?: unknown
211}> implements Respondable.Respondable {
212 [Respondable.symbol]() {
213 return Effect.succeed(Response.empty({ status: 500 }))
214 }
215
216 get methodAndUrl() {
217 return `${this.request.method} ${this.request.url}`
218 }
219
220 override get message() {
221 const info = `${this._tag} (${this.response.status} ${this.methodAndUrl})`
222 return this.description ? `${info}: ${this.description}` : info
223 }
224}
225
226/**
227 * Union of errors that are tied directly to an incoming server request.
228 *
229 * @stability unstable
230 * @category errors
231 * @since 4.0.0
232 */
233export type RequestError = RequestParseError | RouteNotFound | InternalError
234
235/**
236 * Reason carried by an `HttpServerError`, either a request-level error or a response-level error.
237 *
238 * @stability unstable
239 * @category errors
240 * @since 4.0.0
241 */
242export type HttpServerErrorReason = RequestError | ResponseError
243
244/**
245 * Error wrapping a low-level failure from the HTTP server implementation.
246 *
247 * @stability unstable
248 * @category errors
249 * @since 4.0.0
250 */
251export class ServeError extends Data.TaggedError("ServeError")<{
252 readonly cause: unknown
253}> {}
254
255/**
256 * Context annotation used to mark an interrupt as caused by the client aborting
257 * the request.
258 *
259 * **Details**
260 *
261 * `causeResponse` uses this annotation to map a pure client abort to a `499`
262 * response instead of a server abort response.
263 *
264 * @stability unstable
265 * @category services
266 * @since 4.0.0
267 */
268export class ClientAbort extends Context.Service<ClientAbort, true>()("effect/http/HttpServerError/ClientAbort") {
269 static annotation = this.context(true).pipe(
270 Context.add(Cause.StackTrace, {
271 name: "ClientAbort",
272 stack: constUndefined,
273 parent: undefined
274 })
275 )
276}
277
278const formatRequestMessage = (reason: string, description: string | undefined, info: string) => {
279 const prefix = `${reason} (${info})`
280 return description ? `${prefix}: ${description}` : prefix
281}
282
283/**
284 * Converts a failed handler cause into the HTTP response that should be sent and
285 * the cause that should be reported.
286 *
287 * **Details**
288 *
289 * Respondable failures and defects can choose their own response, defects that
290 * are already `HttpServerResponse` values are used directly, and pure interrupts
291 * produce either `499` for client aborts or `503` for server aborts.
292 *
293 * @stability unstable
294 * @category error handling
295 * @since 4.0.0
296 */
297export const causeResponse = <E>(
298 cause: Cause.Cause<E>
299): Effect.Effect<readonly [Response.HttpServerResponse, Cause.Cause<E>]> => {
300 let response: Response.HttpServerResponse | undefined
301 let effect = succeedInternalServerError
302 const failures: Array<Cause.Reason<E>> = []
303 let interrupts: Array<Cause.Interrupt> = []
304 let isClientInterrupt = false
305 for (let i = 0; i < cause.reasons.length; i++) {
306 const reason = cause.reasons[i]
307 switch (reason._tag) {
308 case "Fail": {
309 effect = Respondable.toResponseOrElse(reason.error, internalServerError)
310 failures.push(reason)
311 break
312 }
313 case "Die": {
314 if (Response.isHttpServerResponse(reason.defect)) {
315 response = reason.defect
316 } else {
317 effect = Respondable.toResponseOrElseDefect(reason.defect, internalServerError)
318 failures.push(reason)
319 }
320 break
321 }
322 case "Interrupt": {
323 isClientInterrupt = reason.annotations.has(ClientAbort.key)
324 if (failures.length > 0) break
325 interrupts.push(reason)
326 break
327 }
328 }
329 }
330 if (response) {
331 return Effect.succeed([response, Cause.fromReasons(failures)] as const)
332 } else if (interrupts.length > 0 && failures.length === 0) {
333 failures.push(...interrupts)
334 effect = isClientInterrupt ? clientAbortError : serverAbortError
335 }
336 return Effect.mapEager(effect, (response) => {
337 failures.push(Cause.makeDieReason(response))
338 return [response, Cause.fromReasons(failures)] as const
339 })
340}
341
342/**
343 * Derives an HTTP response from a failed handler cause synchronously.
344 *
345 * **Details**
346 *
347 * If the cause contains a defect that is already an `HttpServerResponse`, that
348 * response is used and removed from the remaining cause. Otherwise the response
349 * defaults to `500`.
350 *
351 * @stability unstable
352 * @category error handling
353 * @since 4.0.0
354 */
355export const causeResponseStripped = <E>(
356 cause: Cause.Cause<E>
357): readonly [response: Response.HttpServerResponse, cause: Option.Option<Cause.Cause<E>>] => {
358 let response: Response.HttpServerResponse | undefined
359 const failures = cause.reasons.filter((f) => {
360 if (f._tag === "Die" && Response.isHttpServerResponse(f.defect)) {
361 response = f.defect
362 return false
363 }
364 return true
365 })
366 return [
367 response ?? internalServerError,
368 failures.length > 0 ? Option.some(Cause.fromReasons(failures)) : Option.none()
369 ]
370}
371
372const internalServerError = Response.empty({ status: 500 })
373const succeedInternalServerError = Effect.succeed(internalServerError)
374const clientAbortError = Effect.succeed(Response.empty({ status: 499 }))
375const serverAbortError = Effect.succeed(Response.empty({ status: 503 }))
376
377/**
378 * Extracts the response from a successful handler exit, or derives a response
379 * from the failure cause.
380 *
381 * @stability unstable
382 * @category error handling
383 * @since 4.0.0
384 */
385export const exitResponse = <E>(exit: Exit.Exit<Response.HttpServerResponse, E>): Response.HttpServerResponse => {
386 if (exit._tag === "Success") {
387 return exit.value
388 }
389 return causeResponseStripped(exit.cause)[0]
390}