A batteries included HTTP/1.1 client in OCaml
at main 454 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 6let src = Logs.Src.create "requests.one" ~doc:"One-shot HTTP Requests" 7 8module Log = (val Logs.src_log src : Logs.LOG) 9 10(* Redirect handling - delegated to shared Redirect module *) 11 12(* Helper to create TCP connection to host:port *) 13let connect_tcp ~sw ~net ~host ~port = 14 Log.debug (fun m -> m "Connecting to %s:%d" host port); 15 (* Resolve hostname to IP address *) 16 let addrs = 17 Eio.Net.getaddrinfo_stream net host ~service:(string_of_int port) 18 in 19 match addrs with 20 | addr :: _ -> 21 Log.debug (fun m -> m "Resolved %s, connecting..." host); 22 Eio.Net.connect ~sw net addr 23 | [] -> 24 Log.err (fun m -> m "Failed to resolve hostname: %s" host); 25 raise (Error.err (Error.Dns_resolution_failed { hostname = host })) 26 27(** Minimum TLS version configuration - re-exported from Tls_config. *) 28type tls_version = Tls_config.tls_version = 29 | TLS_1_2 (** TLS 1.2 minimum (default, widely compatible) *) 30 | TLS_1_3 31 (** TLS 1.3 minimum (most secure, may not work with older servers) *) 32 33(** Negotiated protocol after TLS handshake *) 34type negotiated_protocol = 35 | Http1 (** HTTP/1.x (including plain HTTP) *) 36 | Http2 (** HTTP/2 negotiated via ALPN *) 37 38(* Track whether TLS tracing has been suppressed *) 39let tls_tracing_suppressed = ref false 40 41(* Suppress TLS tracing debug output (hexdumps) unless explicitly enabled *) 42let suppress_tls_tracing () = 43 if not !tls_tracing_suppressed then begin 44 tls_tracing_suppressed := true; 45 match 46 List.find_opt 47 (fun s -> Logs.Src.name s = "tls.tracing") 48 (Logs.Src.list ()) 49 with 50 | Some tls_src -> ( 51 (* Only suppress if currently at Debug level *) 52 match Logs.Src.level tls_src with 53 | Some Logs.Debug -> Logs.Src.set_level tls_src (Some Logs.Warning) 54 | _ -> ()) 55 | None -> () 56 end 57 58(* Helper to wrap connection with TLS if needed. 59 Returns the TLS flow and the negotiated protocol. *) 60let wrap_tls flow ~host ~verify_tls ~tls_config ~min_tls_version = 61 Log.debug (fun m -> 62 m "Wrapping connection with TLS for %s (verify=%b)" host verify_tls); 63 64 (* Get or create TLS config with minimum version enforcement *) 65 let tls_cfg = 66 match tls_config with 67 | Some cfg -> cfg 68 | None -> Tls_config.client ~verify_tls ~min_tls_version ~host () 69 in 70 71 (* Get domain name for SNI *) 72 let domain = 73 match Domain_name.of_string host with 74 | Ok dn -> ( 75 match Domain_name.host dn with 76 | Ok d -> d 77 | Error (`Msg msg) -> 78 Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg); 79 raise 80 (Error.err 81 (Error.Tls_handshake_failed 82 { host; reason = "Invalid hostname: " ^ msg }))) 83 | Error (`Msg msg) -> 84 Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg); 85 raise 86 (Error.err 87 (Error.Tls_handshake_failed 88 { host; reason = "Invalid hostname: " ^ msg })) 89 in 90 91 let tls_flow = Tls_eio.client_of_flow ~host:domain tls_cfg flow in 92 (* Suppress TLS tracing after first connection creates the tls.tracing source *) 93 suppress_tls_tracing (); 94 95 (* Check negotiated ALPN protocol *) 96 let protocol = 97 match Tls_eio.epoch tls_flow with 98 | Ok epoch -> ( 99 match epoch.Tls.Core.alpn_protocol with 100 | Some "h2" -> 101 Log.info (fun m -> m "ALPN negotiated HTTP/2 for %s" host); 102 Http2 103 | Some proto -> 104 Log.debug (fun m -> m "ALPN negotiated %s for %s" proto host); 105 Http1 106 | None -> 107 Log.debug (fun m -> 108 m "No ALPN negotiated for %s, using HTTP/1.1" host); 109 Http1) 110 | Error () -> 111 Log.debug (fun m -> 112 m "Could not get TLS epoch for %s, using HTTP/1.1" host); 113 Http1 114 in 115 116 ( (tls_flow :> [ `Close | `Flow | `R | `Shutdown | `W ] Eio.Resource.t), 117 protocol ) 118 119(* Parse URL and connect directly (no pooling). 120 Returns the flow and the negotiated protocol. *) 121let connect_to_url ~sw ~clock ~net ~url ~timeout ~verify_tls ~tls_config 122 ~min_tls_version = 123 let uri = Uri.of_string url in 124 125 (* Extract host and port *) 126 let host = 127 match Uri.host uri with 128 | Some h -> h 129 | None -> 130 raise 131 (Error.err 132 (Error.Invalid_url { url; reason = "URL must contain a host" })) 133 in 134 135 let is_https = Uri.scheme uri = Some "https" in 136 let default_port = if is_https then 443 else 80 in 137 let port = Option.value (Uri.port uri) ~default:default_port in 138 139 (* Apply connection timeout if specified *) 140 let connect_fn () = 141 let tcp_flow = connect_tcp ~sw ~net ~host ~port in 142 if is_https then 143 wrap_tls tcp_flow ~host ~verify_tls ~tls_config ~min_tls_version 144 else 145 ( (tcp_flow :> [ `Close | `Flow | `R | `Shutdown | `W ] Eio.Resource.t), 146 Http1 ) 147 in 148 149 match Option.bind timeout Timeout.total with 150 | Some seconds -> 151 Log.debug (fun m -> m "Setting connection timeout: %.2f seconds" seconds); 152 Eio.Time.with_timeout_exn clock seconds connect_fn 153 | None -> connect_fn () 154 155let h2_direct_response ~sw ~flow ~uri ~headers ~body ~method_ ~auto_decompress = 156 match 157 H2_adapter.one_request ~sw ~flow ~uri ~headers ~body ~method_ 158 ~auto_decompress () 159 with 160 | Ok resp -> 161 (resp.H2_adapter.status, resp.H2_adapter.headers, resp.H2_adapter.body) 162 | Error msg -> 163 raise 164 (Error.err (Error.Invalid_request { reason = "HTTP/2 error: " ^ msg })) 165 166let direct_request ~sw ~clock ~net ~url ~timeout ~verify_tls ~tls_config 167 ~min_tls_version ~uri ~headers ~body ~method_ ~expect_100 ~auto_decompress = 168 let flow, protocol = 169 connect_to_url ~sw ~clock ~net ~url ~timeout ~verify_tls ~tls_config 170 ~min_tls_version 171 in 172 match protocol with 173 | Http2 -> 174 Log.debug (fun m -> m "[One] Using HTTP/2 for %s" url); 175 h2_direct_response ~sw ~flow ~uri ~headers ~body ~method_ ~auto_decompress 176 | Http1 -> 177 Http_client.request_100_continue_decompress ~expect_100 ~clock ~sw 178 ~method_ ~uri ~headers ~body ~auto_decompress flow 179 180let prepare_one_headers ~headers ~body ~auth ~allow_insecure_auth 181 ~auto_decompress ~url = 182 let headers = Option.value headers ~default:Headers.empty in 183 let headers = 184 if not (Headers.mem `User_agent headers) then 185 Headers.set `User_agent Version.user_agent headers 186 else headers 187 in 188 let headers = 189 Option.fold ~none:headers auth ~some:(fun a -> 190 Log.debug (fun m -> m "Applying authentication"); 191 Auth.apply_secure ~allow_insecure_auth ~url a headers) 192 in 193 let headers = 194 Option.bind body Body.content_type 195 |> Option.fold ~none:headers ~some:(fun mime -> 196 Headers.content_type mime headers) 197 in 198 if auto_decompress && not (Headers.mem `Accept_encoding headers) then 199 Headers.set `Accept_encoding "gzip, deflate" headers 200 else headers 201 202let proxy_http_request ~sw ~net ~clock ~method_ ~uri_to_fetch 203 ~headers_for_request ~request_body ~auto_decompress ~expect_100_config 204 ~proxy:p = 205 Log.debug (fun m -> 206 m "[One] Routing HTTP request via proxy %s:%d" p.Proxy.host p.Proxy.port); 207 let flow = connect_tcp ~sw ~net ~host:p.Proxy.host ~port:p.Proxy.port in 208 let flow = 209 (flow :> [ `Close | `Flow | `R | `Shutdown | `W ] Eio.Resource.t) 210 in 211 let proxy_auth = 212 match p.Proxy.auth with 213 | Some auth -> 214 let auth_headers = Auth.apply auth Headers.empty in 215 Headers.find `Authorization auth_headers 216 | None -> None 217 in 218 Http_write.write_and_flush flow (fun w -> 219 Http_write.request_via_proxy w ~sw ~method_ ~uri:uri_to_fetch 220 ~headers:headers_for_request ~body:request_body ~proxy_auth); 221 let limits = Response_limits.default in 222 let buf_read = Http_read.of_flow ~max_size:65536 flow in 223 let _version, status, resp_headers, body_str = 224 Http_read.response ~limits ~method_ buf_read 225 in 226 let body_str = 227 match (auto_decompress, Headers.find `Content_encoding resp_headers) with 228 | true, Some encoding -> 229 Http_client.decompress_body ~limits ~content_encoding:encoding body_str 230 | _ -> body_str 231 in 232 (status, resp_headers, body_str) 233 234let dispatch_request ~sw ~clock ~net ~method_ ~request_body ~expect_100_config 235 ~proxy ~timeout ~verify_tls ~tls_config ~min_tls_version ~auto_decompress 236 ~uri_to_fetch ~headers_for_request ~use_proxy ~is_https ~url_to_fetch = 237 match (use_proxy, is_https, proxy) with 238 | false, _, _ -> 239 direct_request ~sw ~clock ~net ~url:url_to_fetch ~timeout ~verify_tls 240 ~tls_config ~min_tls_version ~uri:uri_to_fetch 241 ~headers:headers_for_request ~body:request_body ~method_ 242 ~expect_100:expect_100_config ~auto_decompress 243 | true, false, Some p -> 244 proxy_http_request ~sw ~net ~clock ~method_ ~uri_to_fetch 245 ~headers_for_request ~request_body ~auto_decompress ~expect_100_config 246 ~proxy:p 247 | true, true, Some p -> 248 Log.debug (fun m -> 249 m "[One] Routing HTTPS request via proxy %s:%d (CONNECT tunnel)" 250 p.Proxy.host p.Proxy.port); 251 let target_host = Uri.host uri_to_fetch |> Option.value ~default:"" in 252 let target_port = Uri.port uri_to_fetch |> Option.value ~default:443 in 253 let tunnel_flow = 254 Proxy_tunnel.connect_with_tls ~sw ~net ~clock ~proxy:p ~target_host 255 ~target_port ?tls_config () 256 in 257 Http_client.request_100_continue_decompress ~expect_100:expect_100_config 258 ~clock ~sw ~method_ ~uri:uri_to_fetch ~headers:headers_for_request 259 ~body:request_body ~auto_decompress tunnel_flow 260 | true, _, None -> 261 let flow, _protocol = 262 connect_to_url ~sw ~clock ~net ~url:url_to_fetch ~timeout ~verify_tls 263 ~tls_config ~min_tls_version 264 in 265 Http_client.request_100_continue_decompress ~expect_100:expect_100_config 266 ~clock ~sw ~method_ ~uri:uri_to_fetch ~headers:headers_for_request 267 ~body:request_body ~auto_decompress flow 268 269(* Redirect-following loop extracted as module-level function for size *) 270let rec with_redirects ~sw ~clock ~net ~method_ ~request_body ~expect_100_config 271 ~proxy ~timeout ~verify_tls ~tls_config ~min_tls_version ~auto_decompress 272 ~follow_redirects ~max_redirects ~original_uri ~headers_for_request 273 url_to_fetch redirects_left = 274 let uri_to_fetch = Uri.of_string url_to_fetch in 275 let use_proxy = 276 match proxy with 277 | None -> false 278 | Some p -> not (Proxy.should_bypass p url_to_fetch) 279 in 280 let is_https = Uri.scheme uri_to_fetch = Some "https" in 281 let status, resp_headers, response_body_str = 282 dispatch_request ~sw ~clock ~net ~method_ ~request_body ~expect_100_config 283 ~proxy ~timeout ~verify_tls ~tls_config ~min_tls_version ~auto_decompress 284 ~uri_to_fetch ~headers_for_request ~use_proxy ~is_https ~url_to_fetch 285 in 286 Log.info (fun m -> m "Received response: status=%d" status); 287 288 if follow_redirects && status >= 300 && status < 400 then begin 289 if redirects_left <= 0 then begin 290 Log.err (fun m -> 291 m "Too many redirects (%d) for %s" max_redirects 292 (Uri.to_string original_uri)); 293 raise 294 (Error.err 295 (Error.Too_many_redirects 296 { 297 url = Uri.to_string original_uri; 298 count = max_redirects; 299 max = max_redirects; 300 })) 301 end; 302 303 match Headers.find `Location resp_headers with 304 | None -> 305 Log.debug (fun m -> m "Redirect response missing Location header"); 306 (status, resp_headers, response_body_str, url_to_fetch) 307 | Some location -> 308 let _ = Redirect.validate_url location in 309 Log.info (fun m -> 310 m "Following redirect to %s (%d remaining)" location redirects_left); 311 let redirect_uri = Uri.of_string location in 312 let headers_for_redirect = 313 if Redirect.same_origin original_uri redirect_uri then 314 headers_for_request 315 else Redirect.strip_sensitive_headers headers_for_request 316 in 317 with_redirects ~sw ~clock ~net ~method_ ~request_body ~expect_100_config 318 ~proxy ~timeout ~verify_tls ~tls_config ~min_tls_version 319 ~auto_decompress ~follow_redirects ~max_redirects ~original_uri 320 ~headers_for_request:headers_for_redirect location (redirects_left - 1) 321 end 322 else (status, resp_headers, response_body_str, url_to_fetch) 323 324(* Main request implementation - completely stateless *) 325let request ~sw ~clock ~net ?headers ?body ?auth ?timeout 326 ?(follow_redirects = true) ?(max_redirects = 10) ?(verify_tls = true) 327 ?tls_config ?(auto_decompress = true) ?(min_tls_version = TLS_1_2) 328 ?(expect_100_continue = `Threshold Expect_continue.default_threshold) 329 ?(allow_insecure_auth = false) ?proxy ~method_ url = 330 let start_time = Unix.gettimeofday () in 331 Log.debug (fun m -> 332 m "[One] Executing %s request to %s" (Method.to_string method_) url); 333 334 let headers = 335 prepare_one_headers ~headers ~body ~auth ~allow_insecure_auth 336 ~auto_decompress ~url 337 in 338 let request_body = Option.value ~default:Body.empty body in 339 let original_uri = Uri.of_string url in 340 let expect_100_timeout = 341 Option.bind timeout Timeout.expect_100_continue |> Option.value ~default:1.0 342 in 343 let expect_100_config = 344 Expect_continue.of_config ~timeout:expect_100_timeout expect_100_continue 345 in 346 347 let final_status, final_headers, final_body_str, final_url = 348 with_redirects ~sw ~clock ~net ~method_ ~request_body ~expect_100_config 349 ~proxy ~timeout ~verify_tls ~tls_config ~min_tls_version ~auto_decompress 350 ~follow_redirects ~max_redirects ~original_uri 351 ~headers_for_request:headers url max_redirects 352 in 353 354 let elapsed = Unix.gettimeofday () -. start_time in 355 Log.info (fun m -> m "Request completed in %.3f seconds" elapsed); 356 357 let body_flow = Eio.Flow.string_source final_body_str in 358 359 Response.Private.make ~sw ~status:final_status ~headers:final_headers 360 ~body:body_flow ~url:final_url ~elapsed 361 362(* Convenience methods *) 363let get ~sw ~clock ~net ?headers ?auth ?timeout ?follow_redirects ?max_redirects 364 ?verify_tls ?tls_config ?min_tls_version ?allow_insecure_auth ?proxy url = 365 request ~sw ~clock ~net ?headers ?auth ?timeout ?follow_redirects 366 ?max_redirects ?verify_tls ?tls_config ?min_tls_version ?allow_insecure_auth 367 ?proxy ~expect_100_continue:`Disabled (* GET has no body *) ~method_:`GET 368 url 369 370let post ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config 371 ?min_tls_version ?expect_100_continue ?allow_insecure_auth ?proxy url = 372 request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config 373 ?min_tls_version ?expect_100_continue ?allow_insecure_auth ?proxy 374 ~method_:`POST url 375 376let put ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config 377 ?min_tls_version ?expect_100_continue ?allow_insecure_auth ?proxy url = 378 request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config 379 ?min_tls_version ?expect_100_continue ?allow_insecure_auth ?proxy 380 ~method_:`PUT url 381 382let delete ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config 383 ?min_tls_version ?allow_insecure_auth ?proxy url = 384 request ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config 385 ?min_tls_version ?allow_insecure_auth ?proxy 386 ~expect_100_continue:`Disabled (* DELETE typically has no body *) 387 ~method_:`DELETE url 388 389let head ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config 390 ?min_tls_version ?allow_insecure_auth ?proxy url = 391 request ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config 392 ?min_tls_version ?allow_insecure_auth ?proxy 393 ~expect_100_continue:`Disabled (* HEAD has no body *) ~method_:`HEAD url 394 395let patch ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config 396 ?min_tls_version ?expect_100_continue ?allow_insecure_auth ?proxy url = 397 request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?verify_tls ?tls_config 398 ?min_tls_version ?expect_100_continue ?allow_insecure_auth ?proxy 399 ~method_:`PATCH url 400 401let upload ~sw ~clock ~net ?headers ?auth ?timeout ?method_ ?mime ?length 402 ?on_progress ?verify_tls ?tls_config ?min_tls_version ?expect_100_continue 403 ?allow_insecure_auth ?proxy ~source url = 404 let method_ = Option.value method_ ~default:`POST in 405 let mime = Option.value mime ~default:Mime.octet_stream in 406 407 (* Wrap source with progress tracking if callback provided *) 408 let tracked_source = 409 match on_progress with 410 | None -> source 411 | Some callback -> 412 (* For now, progress tracking is not implemented for uploads 413 due to complexity of wrapping Eio.Flow.source. 414 This would require creating a custom flow wrapper. *) 415 let _ = callback in 416 source 417 in 418 419 let body = Body.of_stream ?length mime tracked_source in 420 request ~sw ~clock ~net ?headers ~body ?auth ?timeout ?verify_tls ?tls_config 421 ?min_tls_version ?allow_insecure_auth ?proxy ?expect_100_continue ~method_ 422 url 423 424let download ~sw ~clock ~net ?headers ?auth ?timeout ?on_progress ?verify_tls 425 ?tls_config ?min_tls_version ?allow_insecure_auth ?proxy url ~sink = 426 let response = 427 get ~sw ~clock ~net ?headers ?auth ?timeout ?verify_tls ?tls_config 428 ?min_tls_version ?allow_insecure_auth ?proxy url 429 in 430 431 try 432 (* Get content length for progress tracking *) 433 let total = Response.content_length response in 434 435 let body = Response.body response in 436 437 (* Stream data to sink with optional progress *) 438 match on_progress with 439 | None -> 440 (* No progress tracking, just copy directly *) 441 Eio.Flow.copy body sink 442 | Some progress_fn -> 443 (* Copy with progress tracking *) 444 (* We need to intercept the flow to track bytes *) 445 (* For now, just do a simple copy - proper progress tracking needs flow wrapper *) 446 progress_fn ~received:0L ~total; 447 Eio.Flow.copy body sink; 448 progress_fn ~received:(Option.value total ~default:0L) ~total; 449 450 (* Response auto-closes with switch *) 451 () 452 with e -> 453 (* Response auto-closes with switch *) 454 raise e