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