A batteries included HTTP/1.1 client in OCaml
at main 404 lines 15 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** Centralized error handling for the Requests library using Eio.Io exceptions. 7 8 This module follows the Eio.Io exception pattern for structured error 9 handling, providing granular error types and query functions for smart retry 10 logic. 11 12 {2 Usage} 13 14 Errors are raised using the Eio.Io pattern: 15 {[ 16 raise 17 (Error.err 18 (Error.Timeout { operation = "connect"; duration = Some 30.0 })) 19 ]} 20 21 To catch and handle errors: 22 {[ 23 try 24 (* ... HTTP request ... *) 25 with 26 | Eio.Io (Error.E e, _) when Error.is_retryable e -> 27 (* Retry the request *) 28 | Eio.Io (Error.E e, _) -> 29 Printf.eprintf "Request failed: %s\n" (Error.to_string e) 30 ]} *) 31 32val src : Logs.Src.t 33(** Log source for error reporting. *) 34 35(** {1 Error Type} 36 37 Granular error variants with contextual information. Each variant contains a 38 record with relevant details. *) 39 40type t = 41 (* Timeout errors *) 42 | Timeout of { operation : string; duration : float option } 43 (* Redirect errors *) 44 | Too_many_redirects of { url : string; count : int; max : int } 45 | Invalid_redirect of { url : string; reason : string } 46 (* HTTP response errors *) 47 (* Note: headers stored as list to avoid dependency cycle with Headers module *) 48 | Http_error of { 49 url : string; 50 status : int; 51 reason : string; 52 body_preview : string option; 53 headers : (string * string) list; 54 } 55 (* Authentication errors *) 56 | Authentication_failed of { url : string; reason : string } 57 (* Connection errors - granular breakdown *) 58 | Dns_resolution_failed of { hostname : string } 59 | Tcp_connect_failed of { host : string; port : int; reason : string } 60 | Tls_handshake_failed of { host : string; reason : string } 61 (* Security-related errors *) 62 | Invalid_header of { name : string; reason : string } 63 | Body_too_large of { limit : int64; actual : int64 option } 64 | Headers_too_large of { limit : int; actual : int } 65 | Decompression_bomb of { limit : int64; ratio : float } 66 | Content_length_mismatch of { expected : int64; actual : int64 } 67 | Insecure_auth of { url : string; auth_type : string } 68 (** Per RFC 7617 Section 4 and RFC 6750 Section 5.1: Basic, Bearer, and 69 Digest authentication over unencrypted HTTP exposes credentials to 70 eavesdropping. Raised when attempting to use these auth methods over 71 HTTP without explicit opt-out. *) 72 (* JSON errors *) 73 | Json_parse_error of { body_preview : string; reason : string } 74 | Json_encode_error of { reason : string } 75 (* Other errors *) 76 | Proxy_error of { host : string; reason : string } 77 | Encoding_error of { encoding : string; reason : string } 78 | Invalid_url of { url : string; reason : string } 79 | Invalid_request of { reason : string } 80 (* OAuth 2.0 errors - per RFC 6749 Section 5.2 *) 81 | Oauth_error of { 82 error_code : string; 83 description : string option; 84 uri : string option; 85 } 86 (** OAuth 2.0 error response from authorization server. Per 87 {{:https://datatracker.ietf.org/doc/html/rfc6749#section-5.2}RFC 6749 88 Section 5.2}. *) 89 | Token_refresh_failed of { reason : string } 90 (** Token refresh operation failed. *) 91 | Token_expired 92 (** Access token has expired and no refresh token is available. *) 93 (* HTTP/2 protocol errors - per RFC 9113 *) 94 | H2_protocol_error of { code : int32; message : string } 95 (** HTTP/2 connection error per 96 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.4.1}RFC 97 9113 Section 5.4.1}. Error codes are defined in RFC 9113 Section 7. 98 *) 99 | H2_stream_error of { stream_id : int32; code : int32; message : string } 100 (** HTTP/2 stream error per 101 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.4.2}RFC 102 9113 Section 5.4.2}. *) 103 | H2_flow_control_error of { stream_id : int32 option } 104 (** Flow control window exceeded per 105 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.2}RFC 9113 106 Section 5.2}. *) 107 | H2_compression_error of { message : string } 108 (** HPACK decompression failed per 109 {{:https://datatracker.ietf.org/doc/html/rfc7541}RFC 7541}. *) 110 | H2_settings_timeout 111 (** SETTINGS acknowledgment timeout per 112 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-6.5.3}RFC 113 9113 Section 6.5.3}. *) 114 | H2_goaway of { last_stream_id : int32; code : int32; debug : string } 115 (** Server sent GOAWAY frame per 116 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-6.8}RFC 9113 117 Section 6.8}. *) 118 | H2_frame_error of { frame_type : int; message : string } 119 (** Invalid frame received per RFC 9113 Section 4-6. *) 120 | H2_header_validation_error of { message : string } 121 (** HTTP/2 header validation failed per RFC 9113 Section 8.2-8.3. *) 122 123(** {1 Eio.Exn Integration} *) 124 125(** Extension of [Eio.Exn.err] for Requests errors *) 126type Eio.Exn.err += E of t 127 128val err : t -> exn 129(** [err e] creates an Eio exception from an error. *) 130 131(** {1 URL and Credential Sanitization} *) 132 133val sanitize_url : string -> string 134(** Remove userinfo (username:password) from a URL for safe logging. *) 135 136val sanitize_headers : (string * string) list -> (string * string) list 137(** Redact sensitive headers (Authorization, Cookie, etc.) for safe logging. 138 Takes and returns a list of (name, value) pairs. *) 139 140val is_sensitive_header : string -> bool 141(** Check if a header name is sensitive (case-insensitive). *) 142 143(** {1 Pretty Printing} *) 144 145val pp_error : Format.formatter -> t -> unit 146(** Pretty printer for error values. *) 147 148(** {1 Query Functions} 149 150 These functions enable smart error handling without pattern matching. *) 151 152val is_timeout : t -> bool 153(** [is_timeout e] returns [true] if [e] is a timeout. *) 154 155val is_dns : t -> bool 156(** [is_dns e] returns [true] if [e] is a DNS resolution failure. *) 157 158val is_tls : t -> bool 159(** [is_tls e] returns [true] if [e] is a TLS handshake failure. *) 160 161val is_connection : t -> bool 162(** [is_connection e] returns [true] if [e] is any connection-related failure 163 (DNS, TCP connect, or TLS handshake). *) 164 165val is_http_error : t -> bool 166(** [is_http_error e] returns [true] if [e] is an HTTP response error. *) 167 168val is_client_error : t -> bool 169(** [is_client_error e] returns [true] if [e] is a client error (4xx status or 170 similar). *) 171 172val is_server_error : t -> bool 173(** [is_server_error e] returns [true] if [e] is a server error (5xx status). *) 174 175val is_retryable : t -> bool 176(** [is_retryable e] returns [true] if [e] is typically retryable. Retryable 177 errors include: timeouts, connection errors, and certain HTTP status codes 178 (408, 429, 500, 502, 503, 504). *) 179 180val is_security_error : t -> bool 181(** [is_security_error e] returns [true] if [e] is security-related (header 182 injection, body too large, decompression bomb, etc.). *) 183 184val is_json_error : t -> bool 185(** [is_json_error e] returns [true] if [e] is a JSON parsing or encoding error. 186*) 187 188val is_oauth_error : t -> bool 189(** [is_oauth_error e] returns [true] if [e] is an OAuth-related error 190 (Oauth_error, Token_refresh_failed, Token_expired). *) 191 192(** {1 Error Extraction} *) 193 194val of_eio_exn : exn -> t option 195(** Extract error from an Eio.Io exception, if it's a Requests error. *) 196 197(** {1 HTTP Status Helpers} *) 198 199val http_status : t -> int option 200(** Get the HTTP status code from an error, if applicable. *) 201 202val url : t -> string option 203(** Get the URL associated with an error, if applicable. *) 204 205(** {1 String Conversion} *) 206 207val pp : Format.formatter -> t -> unit 208(** [pp ppf e] pretty-prints the error. *) 209 210val to_string : t -> string 211(** Convert error to human-readable string. *) 212 213(** {1 Convenience Constructors} 214 215 These functions provide a more concise way to create error exceptions 216 compared to the verbose [err (Error_type { field = value; ... })] pattern. 217 218 Example: 219 {[ 220 (* Instead of: *) 221 raise 222 (err (Invalid_request { reason = "missing host" })) 223 (* Use: *) 224 raise 225 (invalid_request ~reason:"missing host") 226 ]} *) 227 228val invalid_request : reason:string -> exn 229(** [invalid_request ~reason] creates an [Invalid_request] exception. *) 230 231val invalid_redirect : url:string -> reason:string -> exn 232(** [invalid_redirect ~url ~reason] creates an [Invalid_redirect] exception. *) 233 234val invalid_url : url:string -> reason:string -> exn 235(** [invalid_url ~url ~reason] creates an [Invalid_url] exception. *) 236 237val timeout : operation:string -> ?duration:float -> unit -> exn 238(** [timeout ~operation ?duration ()] creates a [Timeout] exception. *) 239 240val body_too_large : limit:int64 -> ?actual:int64 -> unit -> exn 241(** [body_too_large ~limit ?actual ()] creates a [Body_too_large] exception. *) 242 243val headers_too_large : limit:int -> actual:int -> exn 244(** [headers_too_large ~limit ~actual] creates a [Headers_too_large] exception. 245*) 246 247val proxy_error : host:string -> reason:string -> exn 248(** [proxy_error ~host ~reason] creates a [Proxy_error] exception. *) 249 250val tls_handshake_failed : host:string -> reason:string -> exn 251(** [tls_handshake_failed ~host ~reason] creates a [Tls_handshake_failed] 252 exception. *) 253 254val tcp_connect_failed : host:string -> port:int -> reason:string -> exn 255(** [tcp_connect_failed ~host ~port ~reason] creates a [Tcp_connect_failed] 256 exception. *) 257 258(** {1 Format String Constructors} 259 260 These functions accept printf-style format strings for the reason field, 261 making error construction more concise when messages need interpolation. 262 263 Example: 264 {[ 265 (* Instead of: *) 266 raise 267 (Error.err 268 (Error.Invalid_request 269 { reason = Fmt.str "Invalid status code: %s" code_str })) 270 (* Use: *) 271 raise 272 (Error.invalid_requestf "Invalid status code: %s" code_str) 273 ]} *) 274 275val invalid_requestf : ('a, Format.formatter, unit, exn) format4 -> 'a 276(** [invalid_requestf fmt] creates an [Invalid_request] exception with a format 277 string. *) 278 279val invalid_redirectf : 280 url:string -> ('a, Format.formatter, unit, exn) format4 -> 'a 281(** [invalid_redirectf ~url fmt] creates an [Invalid_redirect] exception with a 282 format string. *) 283 284val invalid_urlf : url:string -> ('a, Format.formatter, unit, exn) format4 -> 'a 285(** [invalid_urlf ~url fmt] creates an [Invalid_url] exception with a format 286 string. *) 287 288val proxy_errorf : 289 host:string -> ('a, Format.formatter, unit, exn) format4 -> 'a 290(** [proxy_errorf ~host fmt] creates a [Proxy_error] exception with a format 291 string. *) 292 293val tls_handshake_failedf : 294 host:string -> ('a, Format.formatter, unit, exn) format4 -> 'a 295(** [tls_handshake_failedf ~host fmt] creates a [Tls_handshake_failed] exception 296 with a format string. *) 297 298val tcp_connect_failedf : 299 host:string -> port:int -> ('a, Format.formatter, unit, exn) format4 -> 'a 300(** [tcp_connect_failedf ~host ~port fmt] creates a [Tcp_connect_failed] 301 exception with a format string. *) 302 303(** {1 OAuth Error Constructors} *) 304 305val oauth_error : 306 error_code:string -> ?description:string -> ?uri:string -> unit -> exn 307(** [oauth_error ~error_code ?description ?uri ()] creates an [Oauth_error] 308 exception. *) 309 310val token_refresh_failed : reason:string -> exn 311(** [token_refresh_failed ~reason] creates a [Token_refresh_failed] exception. 312*) 313 314val token_expired : unit -> exn 315(** [token_expired ()] creates a [Token_expired] exception. *) 316 317(** {1 HTTP/2 Error Query Functions} 318 319 Query functions for HTTP/2 specific errors per 320 {{:https://datatracker.ietf.org/doc/html/rfc9113}RFC 9113}. *) 321 322val is_h2_error : t -> bool 323(** [is_h2_error e] returns [true] if [e] is any HTTP/2 protocol error. *) 324 325val is_h2_connection_error : t -> bool 326(** [is_h2_connection_error e] returns [true] if [e] is an HTTP/2 327 connection-level error. Connection errors terminate the entire HTTP/2 328 connection. *) 329 330val is_h2_stream_error : t -> bool 331(** [is_h2_stream_error e] returns [true] if [e] is an HTTP/2 stream-level 332 error. Stream errors only affect a single stream. *) 333 334val is_h2_retryable : t -> bool 335(** [is_h2_retryable e] returns [true] if the HTTP/2 error is typically 336 retryable. Retryable errors include: 337 - GOAWAY with NO_ERROR (graceful shutdown) 338 - REFUSED_STREAM (server didn't process the request) 339 - ENHANCE_YOUR_CALM (after backoff). *) 340 341val h2_error_code : t -> int32 option 342(** Get the HTTP/2 error code from an error, if applicable. Error codes are 343 defined in RFC 9113 Section 7. *) 344 345val h2_stream_id : t -> int32 option 346(** Get the stream ID associated with an HTTP/2 error, if applicable. *) 347 348(** {1 HTTP/2 Error Constructors} 349 350 Convenience constructors for HTTP/2 errors per 351 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-7}RFC 9113 Section 352 7}. *) 353 354val h2_protocol_error : code:int32 -> message:string -> exn 355(** [h2_protocol_error ~code ~message] creates an [H2_protocol_error] exception. 356*) 357 358val h2_stream_error : stream_id:int32 -> code:int32 -> message:string -> exn 359(** [h2_stream_error ~stream_id ~code ~message] creates an [H2_stream_error] 360 exception. *) 361 362val h2_flow_control_error : ?stream_id:int32 -> unit -> exn 363(** [h2_flow_control_error ?stream_id ()] creates an [H2_flow_control_error] 364 exception. If [stream_id] is provided, it's a stream-level error; otherwise, 365 it's a connection-level error. *) 366 367val h2_compression_error : message:string -> exn 368(** [h2_compression_error ~message] creates an [H2_compression_error] exception. 369*) 370 371val h2_settings_timeout : unit -> exn 372(** [h2_settings_timeout ()] creates an [H2_settings_timeout] exception. *) 373 374val h2_goaway : last_stream_id:int32 -> code:int32 -> debug:string -> exn 375(** [h2_goaway ~last_stream_id ~code ~debug] creates an [H2_goaway] exception. 376*) 377 378val h2_frame_error : frame_type:int -> message:string -> exn 379(** [h2_frame_error ~frame_type ~message] creates an [H2_frame_error] exception. 380*) 381 382val h2_header_validation_error : message:string -> exn 383(** [h2_header_validation_error ~message] creates an 384 [H2_header_validation_error] exception. *) 385 386(** {2 HTTP/2 Error Code Names} *) 387 388val h2_error_code_name : int32 -> string 389(** [h2_error_code_name code] returns the name of an HTTP/2 error code. Per RFC 390 9113 Section 7: 391 - 0x0: NO_ERROR 392 - 0x1: PROTOCOL_ERROR 393 - 0x2: INTERNAL_ERROR 394 - 0x3: FLOW_CONTROL_ERROR 395 - 0x4: SETTINGS_TIMEOUT 396 - 0x5: STREAM_CLOSED 397 - 0x6: FRAME_SIZE_ERROR 398 - 0x7: REFUSED_STREAM 399 - 0x8: CANCEL 400 - 0x9: COMPRESSION_ERROR 401 - 0xa: CONNECT_ERROR 402 - 0xb: ENHANCE_YOUR_CALM 403 - 0xc: INADEQUATE_SECURITY 404 - 0xd: HTTP_1_1_REQUIRED. *)