A batteries included HTTP/1.1 client in OCaml
at claude-test 274 lines 9.0 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(** {1 URL and Credential Sanitization} 64 65 Per Recommendation #20: Remove sensitive info from error messages *) 66 67let sanitize_url url = 68 try 69 let uri = Uri.of_string url in 70 let sanitized = Uri.with_userinfo uri None in 71 Uri.to_string sanitized 72 with _ -> url (* If parsing fails, return original *) 73 74(** List of header names considered sensitive (lowercase) *) 75let sensitive_header_names = 76 ["authorization"; "cookie"; "proxy-authorization"; "x-api-key"; "api-key"; "set-cookie"] 77 78(** Check if a header name is sensitive (case-insensitive) *) 79let is_sensitive_header name = 80 List.mem (String.lowercase_ascii name) sensitive_header_names 81 82(** Sanitize a header list by redacting sensitive values *) 83let sanitize_headers headers = 84 List.map (fun (name, value) -> 85 if is_sensitive_header name then (name, "[REDACTED]") 86 else (name, value) 87 ) headers 88 89(** {1 Pretty Printing} *) 90 91let pp_error ppf = function 92 | Timeout { operation; duration } -> 93 (match duration with 94 | Some d -> Format.fprintf ppf "Timeout during %s after %.2fs" operation d 95 | None -> Format.fprintf ppf "Timeout during %s" operation) 96 97 | Too_many_redirects { url; count; max } -> 98 Format.fprintf ppf "Too many redirects (%d/%d) for URL: %s" count max (sanitize_url url) 99 100 | Invalid_redirect { url; reason } -> 101 Format.fprintf ppf "Invalid redirect to %s: %s" (sanitize_url url) reason 102 103 | Http_error { url; status; reason; body_preview; headers = _ } -> 104 Format.fprintf ppf "@[<v>HTTP %d %s@ URL: %s" status reason (sanitize_url url); 105 Option.iter (fun body -> 106 let preview = if String.length body > 200 107 then String.sub body 0 200 ^ "..." 108 else body in 109 Format.fprintf ppf "@ Body: %s" preview 110 ) body_preview; 111 Format.fprintf ppf "@]" 112 113 | Authentication_failed { url; reason } -> 114 Format.fprintf ppf "Authentication failed for %s: %s" (sanitize_url url) reason 115 116 | Dns_resolution_failed { hostname } -> 117 Format.fprintf ppf "DNS resolution failed for hostname: %s" hostname 118 119 | Tcp_connect_failed { host; port; reason } -> 120 Format.fprintf ppf "TCP connection to %s:%d failed: %s" host port reason 121 122 | Tls_handshake_failed { host; reason } -> 123 Format.fprintf ppf "TLS handshake with %s failed: %s" host reason 124 125 | Invalid_header { name; reason } -> 126 Format.fprintf ppf "Invalid header '%s': %s" name reason 127 128 | Body_too_large { limit; actual } -> 129 (match actual with 130 | Some a -> Format.fprintf ppf "Response body too large: %Ld bytes (limit: %Ld)" a limit 131 | None -> Format.fprintf ppf "Response body exceeds limit of %Ld bytes" limit) 132 133 | Headers_too_large { limit; actual } -> 134 Format.fprintf ppf "Response headers too large: %d (limit: %d)" actual limit 135 136 | Decompression_bomb { limit; ratio } -> 137 Format.fprintf ppf "Decompression bomb detected: ratio %.1f:1 exceeds limit, max size %Ld bytes" 138 ratio limit 139 140 | Content_length_mismatch { expected; actual } -> 141 Format.fprintf ppf "Content-Length mismatch: expected %Ld bytes, received %Ld bytes" 142 expected actual 143 144 | Insecure_auth { url; auth_type } -> 145 Format.fprintf ppf "%s authentication over unencrypted HTTP rejected for %s. \ 146 Use HTTPS or set allow_insecure_auth=true (not recommended)" 147 auth_type (sanitize_url url) 148 149 | Json_parse_error { body_preview; reason } -> 150 let preview = if String.length body_preview > 100 151 then String.sub body_preview 0 100 ^ "..." 152 else body_preview in 153 Format.fprintf ppf "@[<v>JSON parse error: %s@ Body preview: %s@]" reason preview 154 155 | Json_encode_error { reason } -> 156 Format.fprintf ppf "JSON encode error: %s" reason 157 158 | Proxy_error { host; reason } -> 159 Format.fprintf ppf "Proxy error for %s: %s" host reason 160 161 | Encoding_error { encoding; reason } -> 162 Format.fprintf ppf "Encoding error (%s): %s" encoding reason 163 164 | Invalid_url { url; reason } -> 165 Format.fprintf ppf "Invalid URL '%s': %s" (sanitize_url url) reason 166 167 | Invalid_request { reason } -> 168 Format.fprintf ppf "Invalid request: %s" reason 169 170(** {1 Eio.Exn Integration} 171 172 Following the pattern from ocaml-conpool for structured Eio exceptions *) 173 174type Eio.Exn.err += E of error 175 176let err e = Eio.Exn.create (E e) 177 178let () = 179 Eio.Exn.register_pp (fun f -> function 180 | E e -> 181 Format.fprintf f "Requests: "; 182 pp_error f e; 183 true 184 | _ -> false) 185 186(** {1 Query Functions} 187 188 Per Recommendation #17: Enable smarter retry logic and error handling *) 189 190let is_timeout = function 191 | Timeout _ -> true 192 | _ -> false 193 194let is_dns = function 195 | Dns_resolution_failed _ -> true 196 | _ -> false 197 198let is_tls = function 199 | Tls_handshake_failed _ -> true 200 | _ -> false 201 202let is_connection = function 203 | Dns_resolution_failed _ -> true 204 | Tcp_connect_failed _ -> true 205 | Tls_handshake_failed _ -> true 206 | _ -> false 207 208let is_http_error = function 209 | Http_error _ -> true 210 | _ -> false 211 212let is_client_error = function 213 | Http_error { status; _ } -> status >= 400 && status < 500 214 | Authentication_failed _ -> true 215 | Invalid_url _ -> true 216 | Invalid_request _ -> true 217 | Invalid_header _ -> true 218 | _ -> false 219 220let is_server_error = function 221 | Http_error { status; _ } -> status >= 500 && status < 600 222 | _ -> false 223 224let is_retryable = function 225 | Timeout _ -> true 226 | Dns_resolution_failed _ -> true 227 | Tcp_connect_failed _ -> true 228 | Tls_handshake_failed _ -> true 229 | Http_error { status; _ } -> 230 (* Retryable status codes: 408, 429, 500, 502, 503, 504 *) 231 List.mem status [408; 429; 500; 502; 503; 504] 232 | Proxy_error _ -> true 233 | _ -> false 234 235let is_security_error = function 236 | Invalid_header _ -> true 237 | Body_too_large _ -> true 238 | Headers_too_large _ -> true 239 | Decompression_bomb _ -> true 240 | Invalid_redirect _ -> true 241 | Insecure_auth _ -> true 242 | _ -> false 243 244let is_json_error = function 245 | Json_parse_error _ -> true 246 | Json_encode_error _ -> true 247 | _ -> false 248 249(** {1 Error Extraction} 250 251 Extract error from Eio.Io exception *) 252 253let of_eio_exn = function 254 | Eio.Io (E e, _) -> Some e 255 | _ -> None 256 257(** {1 HTTP Status Helpers} *) 258 259let get_http_status = function 260 | Http_error { status; _ } -> Some status 261 | _ -> None 262 263let get_url = function 264 | Too_many_redirects { url; _ } -> Some url 265 | Invalid_redirect { url; _ } -> Some url 266 | Http_error { url; _ } -> Some url 267 | Authentication_failed { url; _ } -> Some url 268 | Invalid_url { url; _ } -> Some url 269 | _ -> None 270 271(** {1 String Conversion} *) 272 273let to_string e = 274 Format.asprintf "%a" pp_error e