| 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 | */ |
| 14 | import * as Cause from "../Cause.ts" |
| 15 | import * as Context from "../Context.ts" |
| 16 | import * as Data from "../Data.ts" |
| 17 | import * as Effect from "../Effect.ts" |
| 18 | import * as ErrorReporter from "../ErrorReporter.ts" |
| 19 | import type * as Exit from "../Exit.ts" |
| 20 | import { constUndefined } from "../Function.ts" |
| 21 | import * as Option from "../Option.ts" |
| 22 | import { hasProperty } from "../Predicate.ts" |
| 23 | import type * as Request from "./HttpServerRequest.ts" |
| 24 | import * as Respondable from "./HttpServerRespondable.ts" |
| 25 | import * as Response from "./HttpServerResponse.ts" |
| 26 | |
| 27 | const 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 | */ |
| 42 | export 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 | */ |
| 94 | export 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 | */ |
| 130 | export 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 | */ |
| 161 | export 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 | */ |
| 192 | export 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 | */ |
| 206 | export 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 | */ |
| 233 | export 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 | */ |
| 242 | export 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 | */ |
| 251 | export 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 | */ |
| 268 | export 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 | |
| 278 | const 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 | */ |
| 297 | export 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 | */ |
| 355 | export 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 | |
| 372 | const internalServerError = Response.empty({ status: 500 }) |
| 373 | const succeedInternalServerError = Effect.succeed(internalServerError) |
| 374 | const clientAbortError = Effect.succeed(Response.empty({ status: 499 })) |
| 375 | const 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 | */ |
| 385 | export 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 | } |