A batteries included HTTP/1.1 client in OCaml
at main 478 lines 18 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 9let src = Logs.Src.create "requests.error" ~doc:"HTTP Request Errors" 10 11module Log = (val Logs.src_log src : Logs.LOG) 12 13(** {1 Error Type} 14 15 Following the Eio.Io exception pattern for structured error handling. Each 16 variant contains a record with contextual information. *) 17 18type t = 19 (* Timeout errors *) 20 | Timeout of { operation : string; duration : float option } 21 (* Redirect errors *) 22 | Too_many_redirects of { url : string; count : int; max : int } 23 | Invalid_redirect of { url : string; reason : string } 24 (* HTTP response errors *) 25 (* Note: headers stored as list to avoid dependency cycle with Headers module *) 26 | Http_error of { 27 url : string; 28 status : int; 29 reason : string; 30 body_preview : string option; 31 headers : (string * string) list; 32 } 33 (* Authentication errors *) 34 | Authentication_failed of { url : string; reason : string } 35 (* Connection errors - granular breakdown per Recommendation #17 *) 36 | Dns_resolution_failed of { hostname : string } 37 | Tcp_connect_failed of { host : string; port : int; reason : string } 38 | Tls_handshake_failed of { host : string; reason : string } 39 (* Security-related errors *) 40 | Invalid_header of { name : string; reason : string } 41 | Body_too_large of { limit : int64; actual : int64 option } 42 | Headers_too_large of { limit : int; actual : int } 43 | Decompression_bomb of { limit : int64; ratio : float } 44 | Content_length_mismatch of { expected : int64; actual : int64 } 45 | Insecure_auth of { url : string; auth_type : string } 46 (** Per RFC 7617 Section 4 and RFC 6750 Section 5.1: Basic, Bearer, and 47 Digest authentication over unencrypted HTTP exposes credentials to 48 eavesdropping. *) 49 (* JSON errors *) 50 | Json_parse_error of { body_preview : string; reason : string } 51 | Json_encode_error of { reason : string } 52 (* Other errors *) 53 | Proxy_error of { host : string; reason : string } 54 | Encoding_error of { encoding : string; reason : string } 55 | Invalid_url of { url : string; reason : string } 56 | Invalid_request of { reason : string } 57 (* OAuth 2.0 errors - per RFC 6749 Section 5.2 *) 58 | Oauth_error of { 59 error_code : string; 60 description : string option; 61 uri : string option; 62 } 63 (** OAuth 2.0 error response from authorization server. Per 64 {{:https://datatracker.ietf.org/doc/html/rfc6749#section-5.2}RFC 6749 65 Section 5.2}. *) 66 | Token_refresh_failed of { reason : string } 67 (** Token refresh operation failed. *) 68 | Token_expired 69 (** Access token has expired and no refresh token is available. *) 70 (* HTTP/2 protocol errors - per RFC 9113 *) 71 | H2_protocol_error of { code : int32; message : string } 72 (** HTTP/2 connection error per 73 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.4.1}RFC 74 9113 Section 5.4.1}. Error codes are defined in RFC 9113 Section 7. 75 *) 76 | H2_stream_error of { stream_id : int32; code : int32; message : string } 77 (** HTTP/2 stream error per 78 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.4.2}RFC 79 9113 Section 5.4.2}. *) 80 | H2_flow_control_error of { stream_id : int32 option } 81 (** Flow control window exceeded per 82 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.2}RFC 9113 83 Section 5.2}. *) 84 | H2_compression_error of { message : string } 85 (** HPACK decompression failed per 86 {{:https://datatracker.ietf.org/doc/html/rfc7541}RFC 7541}. *) 87 | H2_settings_timeout 88 (** SETTINGS acknowledgment timeout per 89 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-6.5.3}RFC 90 9113 Section 6.5.3}. *) 91 | H2_goaway of { last_stream_id : int32; code : int32; debug : string } 92 (** Server sent GOAWAY frame per 93 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-6.8}RFC 9113 94 Section 6.8}. *) 95 | H2_frame_error of { frame_type : int; message : string } 96 (** Invalid frame received per RFC 9113 Section 4-6. *) 97 | H2_header_validation_error of { message : string } 98 (** HTTP/2 header validation failed per RFC 9113 Section 8.2-8.3. *) 99 100(** {1 URL and Credential Sanitization} 101 102 Per Recommendation #20: Remove sensitive info from error messages *) 103 104let sanitize_url url = 105 try 106 let uri = Uri.of_string url in 107 let sanitized = Uri.with_userinfo uri None in 108 Uri.to_string sanitized 109 with Invalid_argument _ | Failure _ -> 110 url (* If parsing fails, return original *) 111 112(** List of header names considered sensitive (lowercase) *) 113let sensitive_header_names = 114 [ 115 "authorization"; 116 "cookie"; 117 "csrf-token"; 118 "proxy-authorization"; 119 "x-api-key"; 120 "x-csrf-token"; 121 "x-xsrf-token"; 122 "api-key"; 123 "set-cookie"; 124 ] 125 126(** Check if a header name is sensitive (case-insensitive) *) 127let is_sensitive_header name = 128 List.mem (String.lowercase_ascii name) sensitive_header_names 129 130(** Sanitize a header list by redacting sensitive values *) 131let sanitize_headers headers = 132 List.map 133 (fun (name, value) -> 134 if is_sensitive_header name then (name, "[REDACTED]") else (name, value)) 135 headers 136 137(** {1 Pretty Printing} *) 138 139let pp_error ppf = function 140 | Timeout { operation; duration } -> ( 141 match duration with 142 | Some d -> Fmt.pf ppf "Timeout during %s after %.2fs" operation d 143 | None -> Fmt.pf ppf "Timeout during %s" operation) 144 | Too_many_redirects { url; count; max } -> 145 Fmt.pf ppf "Too many redirects (%d/%d) for URL: %s" count max 146 (sanitize_url url) 147 | Invalid_redirect { url; reason } -> 148 Fmt.pf ppf "Invalid redirect to %s: %s" (sanitize_url url) reason 149 | Http_error { url; status; reason; body_preview; headers = _ } -> 150 Fmt.pf ppf "@[<v>HTTP %d %s@ URL: %s" status reason (sanitize_url url); 151 Option.iter 152 (fun body -> 153 let preview = 154 if String.length body > 200 then String.sub body 0 200 ^ "..." 155 else body 156 in 157 Fmt.pf ppf "@ Body: %s" preview) 158 body_preview; 159 Fmt.pf ppf "@]" 160 | Authentication_failed { url; reason } -> 161 Fmt.pf ppf "Authentication failed for %s: %s" (sanitize_url url) reason 162 | Dns_resolution_failed { hostname } -> 163 Fmt.pf ppf "DNS resolution failed for hostname: %s" hostname 164 | Tcp_connect_failed { host; port; reason } -> 165 Fmt.pf ppf "TCP connection to %s:%d failed: %s" host port reason 166 | Tls_handshake_failed { host; reason } -> 167 Fmt.pf ppf "TLS handshake with %s failed: %s" host reason 168 | Invalid_header { name; reason } -> 169 Fmt.pf ppf "Invalid header '%s': %s" name reason 170 | Body_too_large { limit; actual } -> ( 171 match actual with 172 | Some a -> 173 Fmt.pf ppf "Response body too large: %Ld bytes (limit: %Ld)" a limit 174 | None -> Fmt.pf ppf "Response body exceeds limit of %Ld bytes" limit) 175 | Headers_too_large { limit; actual } -> 176 Fmt.pf ppf "Response headers too large: %d (limit: %d)" actual limit 177 | Decompression_bomb { limit; ratio } -> 178 Fmt.pf ppf 179 "Decompression bomb detected: ratio %.1f:1 exceeds limit, max size %Ld \ 180 bytes" 181 ratio limit 182 | Content_length_mismatch { expected; actual } -> 183 Fmt.pf ppf 184 "Content-Length mismatch: expected %Ld bytes, received %Ld bytes" 185 expected actual 186 | Insecure_auth { url; auth_type } -> 187 Fmt.pf ppf 188 "%s authentication over unencrypted HTTP rejected for %s. Use HTTPS or \ 189 set allow_insecure_auth=true (not recommended)" 190 auth_type (sanitize_url url) 191 | Json_parse_error { body_preview; reason } -> 192 let preview = 193 if String.length body_preview > 100 then 194 String.sub body_preview 0 100 ^ "..." 195 else body_preview 196 in 197 Fmt.pf ppf "@[<v>JSON parse error: %s@ Body preview: %s@]" reason preview 198 | Json_encode_error { reason } -> Fmt.pf ppf "JSON encode error: %s" reason 199 | Proxy_error { host; reason } -> 200 Fmt.pf ppf "Proxy error for %s: %s" host reason 201 | Encoding_error { encoding; reason } -> 202 Fmt.pf ppf "Encoding error (%s): %s" encoding reason 203 | Invalid_url { url; reason } -> 204 Fmt.pf ppf "Invalid URL '%s': %s" (sanitize_url url) reason 205 | Invalid_request { reason } -> Fmt.pf ppf "Invalid request: %s" reason 206 | Oauth_error { error_code; description; uri } -> 207 Fmt.pf ppf "OAuth error: %s" error_code; 208 Option.iter (fun desc -> Fmt.pf ppf " - %s" desc) description; 209 Option.iter (fun u -> Fmt.pf ppf " (see: %s)" u) uri 210 | Token_refresh_failed { reason } -> 211 Fmt.pf ppf "Token refresh failed: %s" reason 212 | Token_expired -> 213 Fmt.pf ppf "Access token expired and no refresh token available" 214 (* HTTP/2 errors *) 215 | H2_protocol_error { code; message } -> 216 Fmt.pf ppf "HTTP/2 protocol error (code 0x%02lx): %s" code message 217 | H2_stream_error { stream_id; code; message } -> 218 Fmt.pf ppf "HTTP/2 stream %ld error (code 0x%02lx): %s" stream_id code 219 message 220 | H2_flow_control_error { stream_id } -> ( 221 match stream_id with 222 | Some id -> Fmt.pf ppf "HTTP/2 flow control error on stream %ld" id 223 | None -> Fmt.pf ppf "HTTP/2 connection flow control error") 224 | H2_compression_error { message } -> 225 Fmt.pf ppf "HTTP/2 HPACK compression error: %s" message 226 | H2_settings_timeout -> Fmt.pf ppf "HTTP/2 SETTINGS acknowledgment timeout" 227 | H2_goaway { last_stream_id; code; debug } -> 228 Fmt.pf ppf "HTTP/2 GOAWAY received (last_stream=%ld, code=0x%02lx): %s" 229 last_stream_id code debug 230 | H2_frame_error { frame_type; message } -> 231 Fmt.pf ppf "HTTP/2 frame error (type 0x%02x): %s" frame_type message 232 | H2_header_validation_error { message } -> 233 Fmt.pf ppf "HTTP/2 header validation error: %s" message 234 235(** {1 Eio.Exn Integration} 236 237 Following the pattern from ocaml-conpool for structured Eio exceptions *) 238 239type Eio.Exn.err += E of t 240 241let err e = Eio.Exn.create (E e) 242 243let () = 244 Eio.Exn.register_pp (fun f -> function 245 | E e -> 246 Fmt.pf f "Requests: "; 247 pp_error f e; 248 true 249 | _ -> false) 250 251(** {1 Query Functions} 252 253 Per Recommendation #17: Enable smarter retry logic and error handling *) 254 255let is_timeout = function Timeout _ -> true | _ -> false 256let is_dns = function Dns_resolution_failed _ -> true | _ -> false 257let is_tls = function Tls_handshake_failed _ -> true | _ -> false 258 259let is_connection = function 260 | Dns_resolution_failed _ -> true 261 | Tcp_connect_failed _ -> true 262 | Tls_handshake_failed _ -> true 263 | _ -> false 264 265let is_http_error = function Http_error _ -> true | _ -> false 266 267let is_client_error = function 268 | Http_error { status; _ } -> status >= 400 && status < 500 269 | Authentication_failed _ -> true 270 | Invalid_url _ -> true 271 | Invalid_request _ -> true 272 | Invalid_header _ -> true 273 | _ -> false 274 275let is_server_error = function 276 | Http_error { status; _ } -> status >= 500 && status < 600 277 | _ -> false 278 279let is_retryable = function 280 | Timeout _ -> true 281 | Dns_resolution_failed _ -> true 282 | Tcp_connect_failed _ -> true 283 | Tls_handshake_failed _ -> true 284 | Http_error { status; _ } -> 285 (* Retryable status codes: 408, 429, 500, 502, 503, 504 *) 286 List.mem status [ 408; 429; 500; 502; 503; 504 ] 287 | Proxy_error _ -> true 288 | _ -> false 289 290let is_security_error = function 291 | Invalid_header _ -> true 292 | Body_too_large _ -> true 293 | Headers_too_large _ -> true 294 | Decompression_bomb _ -> true 295 | Invalid_redirect _ -> true 296 | Insecure_auth _ -> true 297 | _ -> false 298 299let is_json_error = function 300 | Json_parse_error _ -> true 301 | Json_encode_error _ -> true 302 | _ -> false 303 304let is_oauth_error = function 305 | Oauth_error _ -> true 306 | Token_refresh_failed _ -> true 307 | Token_expired -> true 308 | _ -> false 309 310(** {1 HTTP/2 Error Query Functions} *) 311 312let is_h2_error = function 313 | H2_protocol_error _ -> true 314 | H2_stream_error _ -> true 315 | H2_flow_control_error _ -> true 316 | H2_compression_error _ -> true 317 | H2_settings_timeout -> true 318 | H2_goaway _ -> true 319 | H2_frame_error _ -> true 320 | H2_header_validation_error _ -> true 321 | _ -> false 322 323let is_h2_connection_error = function 324 | H2_protocol_error _ -> true 325 | H2_flow_control_error { stream_id = None } -> true 326 | H2_compression_error _ -> true 327 | H2_settings_timeout -> true 328 | H2_goaway _ -> true 329 | _ -> false 330 331let is_h2_stream_error = function 332 | H2_stream_error _ -> true 333 | H2_flow_control_error { stream_id = Some _ } -> true 334 | _ -> false 335 336let is_h2_retryable = function 337 (* GOAWAY with NO_ERROR is graceful shutdown - safe to retry *) 338 | H2_goaway { code = 0l; _ } -> true 339 (* REFUSED_STREAM means server didn't process, safe to retry *) 340 | H2_stream_error { code = 0x7l; _ } -> true 341 | H2_protocol_error { code = 0x7l; _ } -> true 342 (* ENHANCE_YOUR_CALM might be retryable after backoff *) 343 | H2_stream_error { code = 0xbl; _ } -> true 344 | _ -> false 345 346let h2_error_code = function 347 | H2_protocol_error { code; _ } -> Some code 348 | H2_stream_error { code; _ } -> Some code 349 | H2_goaway { code; _ } -> Some code 350 | _ -> None 351 352let h2_stream_id = function 353 | H2_stream_error { stream_id; _ } -> Some stream_id 354 | H2_flow_control_error { stream_id } -> stream_id 355 | H2_goaway { last_stream_id; _ } -> Some last_stream_id 356 | _ -> None 357 358(** {1 Error Extraction} 359 360 Extract error from Eio.Io exception *) 361 362let of_eio_exn = function Eio.Io (E e, _) -> Some e | _ -> None 363 364(** {1 HTTP Status Helpers} *) 365 366let http_status = function Http_error { status; _ } -> Some status | _ -> None 367 368let url = function 369 | Too_many_redirects { url; _ } -> Some url 370 | Invalid_redirect { url; _ } -> Some url 371 | Http_error { url; _ } -> Some url 372 | Authentication_failed { url; _ } -> Some url 373 | Invalid_url { url; _ } -> Some url 374 | _ -> None 375 376(** {1 String Conversion} *) 377 378let pp = pp_error 379let to_string e = Fmt.str "%a" pp_error e 380 381(** {1 Convenience Constructors} 382 383 These functions provide a more concise way to raise common errors compared 384 to the verbose [raise (err (Error_type { field = value; ... }))] pattern. *) 385 386let invalid_request ~reason = err (Invalid_request { reason }) 387let invalid_redirect ~url ~reason = err (Invalid_redirect { url; reason }) 388let invalid_url ~url ~reason = err (Invalid_url { url; reason }) 389let timeout ~operation ?duration () = err (Timeout { operation; duration }) 390let body_too_large ~limit ?actual () = err (Body_too_large { limit; actual }) 391let headers_too_large ~limit ~actual = err (Headers_too_large { limit; actual }) 392let proxy_error ~host ~reason = err (Proxy_error { host; reason }) 393 394let tls_handshake_failed ~host ~reason = 395 err (Tls_handshake_failed { host; reason }) 396 397let tcp_connect_failed ~host ~port ~reason = 398 err (Tcp_connect_failed { host; port; reason }) 399 400(** {1 Format String Constructors} 401 402 These functions accept printf-style format strings for the reason field, 403 making error construction more concise when messages need interpolation. *) 404 405let invalid_requestf fmt = 406 Fmt.kstr (fun reason -> err (Invalid_request { reason })) fmt 407 408let invalid_redirectf ~url fmt = 409 Fmt.kstr (fun reason -> err (Invalid_redirect { url; reason })) fmt 410 411let invalid_urlf ~url fmt = 412 Fmt.kstr (fun reason -> err (Invalid_url { url; reason })) fmt 413 414let proxy_errorf ~host fmt = 415 Fmt.kstr (fun reason -> err (Proxy_error { host; reason })) fmt 416 417let tls_handshake_failedf ~host fmt = 418 Fmt.kstr (fun reason -> err (Tls_handshake_failed { host; reason })) fmt 419 420let tcp_connect_failedf ~host ~port fmt = 421 Fmt.kstr (fun reason -> err (Tcp_connect_failed { host; port; reason })) fmt 422 423(** {1 OAuth Error Constructors} *) 424 425let oauth_error ~error_code ?description ?uri () = 426 err (Oauth_error { error_code; description; uri }) 427 428let token_refresh_failed ~reason = err (Token_refresh_failed { reason }) 429let token_expired () = err Token_expired 430 431(** {1 HTTP/2 Error Constructors} 432 433 Per 434 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-7}RFC 9113 Section 435 7}. *) 436 437let h2_protocol_error ~code ~message = err (H2_protocol_error { code; message }) 438 439let h2_stream_error ~stream_id ~code ~message = 440 err (H2_stream_error { stream_id; code; message }) 441 442let h2_flow_control_error ?stream_id () = 443 err (H2_flow_control_error { stream_id }) 444 445let h2_compression_error ~message = err (H2_compression_error { message }) 446let h2_settings_timeout () = err H2_settings_timeout 447 448let h2_goaway ~last_stream_id ~code ~debug = 449 err (H2_goaway { last_stream_id; code; debug }) 450 451let h2_frame_error ~frame_type ~message = 452 err (H2_frame_error { frame_type; message }) 453 454let h2_header_validation_error ~message = 455 err (H2_header_validation_error { message }) 456 457(** {2 HTTP/2 Error Code Names} 458 459 Per 460 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-7}RFC 9113 Section 461 7}. *) 462 463let h2_error_code_name = function 464 | 0x0l -> "NO_ERROR" 465 | 0x1l -> "PROTOCOL_ERROR" 466 | 0x2l -> "INTERNAL_ERROR" 467 | 0x3l -> "FLOW_CONTROL_ERROR" 468 | 0x4l -> "SETTINGS_TIMEOUT" 469 | 0x5l -> "STREAM_CLOSED" 470 | 0x6l -> "FRAME_SIZE_ERROR" 471 | 0x7l -> "REFUSED_STREAM" 472 | 0x8l -> "CANCEL" 473 | 0x9l -> "COMPRESSION_ERROR" 474 | 0xal -> "CONNECT_ERROR" 475 | 0xbl -> "ENHANCE_YOUR_CALM" 476 | 0xcl -> "INADEQUATE_SECURITY" 477 | 0xdl -> "HTTP_1_1_REQUIRED" 478 | code -> Fmt.str "UNKNOWN(0x%lx)" code