My agentic slop goes here. Not intended for anyone else!
at main 20 kB view raw
1(** OCaml HTTP client library with streaming support *) 2 3let src = Logs.Src.create "requests" ~doc:"HTTP Client Library" 4module Log = (val Logs.src_log src : Logs.LOG) 5 6module Method = Method 7module Mime = Mime 8module Headers = Headers 9module Auth = Auth 10module Timeout = Timeout 11module Body = Body 12module Response = Response 13module One = One 14module Status = Status 15module Error = Error 16module Retry = Retry 17 18(* Note: RNG initialization should be done by the application using 19 Mirage_crypto_rng_unix.initialize before calling Eio_main.run. 20 We don't call use_default() here as it spawns background threads 21 that are incompatible with Eio's structured concurrency. *) 22 23(* Main API - Session functionality with connection pooling *) 24 25type ('clock, 'net) t = { 26 sw : Eio.Switch.t; 27 clock : 'clock; 28 net : 'net; 29 http_pool : ('clock, 'net) Conpool.t; 30 https_pool : ('clock, 'net) Conpool.t; 31 cookie_jar : Cookeio.jar; 32 cookie_mutex : Eio.Mutex.t; 33 default_headers : Headers.t; 34 auth : Auth.t option; 35 timeout : Timeout.t; 36 follow_redirects : bool; 37 max_redirects : int; 38 verify_tls : bool; 39 tls_config : Tls.Config.client option; 40 retry : Retry.config option; 41 persist_cookies : bool; 42 xdg : Xdge.t option; 43 44 (* Statistics - mutable for tracking across all derived sessions *) 45 mutable requests_made : int; 46 mutable total_time : float; 47 mutable retries_count : int; 48} 49 50let create 51 ~sw 52 ?http_pool 53 ?https_pool 54 ?cookie_jar 55 ?(default_headers = Headers.empty) 56 ?auth 57 ?(timeout = Timeout.default) 58 ?(follow_redirects = true) 59 ?(max_redirects = 10) 60 ?(verify_tls = true) 61 ?tls_config 62 ?(max_connections_per_host = 10) 63 ?(connection_idle_timeout = 60.0) 64 ?(connection_lifetime = 300.0) 65 ?retry 66 ?(persist_cookies = false) 67 ?xdg 68 env = 69 70 let clock = env#clock in 71 let net = env#net in 72 73 let xdg = match xdg, persist_cookies with 74 | Some x, _ -> Some x 75 | None, true -> Some (Xdge.create env#fs "requests") 76 | None, false -> None 77 in 78 79 (* Create TLS config for HTTPS pool if needed *) 80 let tls_config = match tls_config, verify_tls with 81 | Some cfg, _ -> Some cfg 82 | None, true -> 83 (* Use CA certificates for verification *) 84 (match Ca_certs.authenticator () with 85 | Ok authenticator -> 86 (match Tls.Config.client ~authenticator () with 87 | Ok cfg -> Some cfg 88 | Error (`Msg msg) -> 89 Log.warn (fun m -> m "Failed to create TLS config: %s" msg); 90 None) 91 | Error (`Msg msg) -> 92 Log.warn (fun m -> m "Failed to load CA certificates: %s" msg); 93 None) 94 | None, false -> None 95 in 96 97 (* Create connection pools if not provided *) 98 let pool_config = Conpool.Config.make 99 ~max_connections_per_endpoint:max_connections_per_host 100 ~max_idle_time:connection_idle_timeout 101 ~max_connection_lifetime:connection_lifetime 102 () 103 in 104 105 (* HTTP pool - plain TCP connections *) 106 let http_pool = match http_pool with 107 | Some p -> p 108 | None -> 109 Conpool.create ~sw ~net ~clock ~config:pool_config () 110 in 111 112 (* HTTPS pool - TLS-wrapped connections *) 113 let https_pool = match https_pool with 114 | Some p -> p 115 | None -> 116 let https_tls_config = Option.map (fun cfg -> 117 Conpool.Tls_config.make ~config:cfg () 118 ) tls_config in 119 Conpool.create ~sw ~net ~clock ?tls:https_tls_config ~config:pool_config () 120 in 121 122 Log.info (fun m -> m "Created Requests session with connection pools (max_per_host=%d, TLS=%b)" 123 max_connections_per_host (Option.is_some tls_config)); 124 125 let cookie_jar = match cookie_jar, persist_cookies, xdg with 126 | Some jar, _, _ -> jar 127 | None, true, Some xdg_ctx -> 128 let data_dir = Xdge.data_dir xdg_ctx in 129 let cookie_file = Eio.Path.(data_dir / "cookies.txt") in 130 Cookeio.load cookie_file 131 | None, _, _ -> 132 Cookeio.create () 133 in 134 135 { 136 sw; 137 clock; 138 net; 139 http_pool; 140 https_pool; 141 cookie_jar; 142 cookie_mutex = Eio.Mutex.create (); 143 default_headers; 144 auth; 145 timeout; 146 follow_redirects; 147 max_redirects; 148 verify_tls; 149 tls_config; 150 retry; 151 persist_cookies; 152 xdg; 153 requests_made = 0; 154 total_time = 0.0; 155 retries_count = 0; 156 } 157 158let set_default_header t key value = 159 { t with default_headers = Headers.set key value t.default_headers } 160 161let remove_default_header t key = 162 { t with default_headers = Headers.remove key t.default_headers } 163 164let set_auth t auth = 165 Log.debug (fun m -> m "Setting authentication method"); 166 { t with auth = Some auth } 167 168let clear_auth t = 169 Log.debug (fun m -> m "Clearing authentication"); 170 { t with auth = None } 171 172let set_timeout t timeout = 173 Log.debug (fun m -> m "Setting timeout: %a" Timeout.pp timeout); 174 { t with timeout } 175 176let set_retry t config = 177 Log.debug (fun m -> m "Setting retry config: max_retries=%d" config.Retry.max_retries); 178 { t with retry = Some config } 179 180let cookies t = t.cookie_jar 181let clear_cookies t = Cookeio.clear t.cookie_jar 182 183(* Internal request function using connection pools *) 184let make_request_internal t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url = 185 let start_time = Unix.gettimeofday () in 186 let method_str = Method.to_string method_ in 187 188 Log.info (fun m -> m "Making %s request to %s" method_str url); 189 190 (* Parse URL *) 191 let uri = Uri.of_string url in 192 let domain = Option.value ~default:"" (Uri.host uri) in 193 let path = Uri.path uri in 194 let is_secure = Uri.scheme uri = Some "https" in 195 196 (* Merge headers *) 197 let headers = match headers with 198 | Some h -> Headers.merge t.default_headers h 199 | None -> t.default_headers 200 in 201 202 (* Use provided auth or default *) 203 let auth = match auth with 204 | Some a -> Some a 205 | None -> t.auth 206 in 207 208 (* Apply auth *) 209 let headers = match auth with 210 | Some a -> 211 Log.debug (fun m -> m "Applying authentication"); 212 Auth.apply a headers 213 | None -> headers 214 in 215 216 (* Add content type from body *) 217 let headers = match body with 218 | Some b -> (match Body.content_type b with 219 | Some mime -> Headers.content_type mime headers 220 | None -> headers) 221 | None -> headers 222 in 223 224 (* Get cookies for this URL *) 225 let headers = 226 Eio.Mutex.use_ro t.cookie_mutex (fun () -> 227 let cookies = Cookeio.get_cookies t.cookie_jar ~domain ~path ~is_secure in 228 match cookies with 229 | [] -> headers 230 | cookies -> 231 Log.debug (fun m -> m "Adding %d cookies for %s%s" (List.length cookies) domain path); 232 let cookie_header = Cookeio.make_cookie_header cookies in 233 Headers.set "Cookie" cookie_header headers 234 ) 235 in 236 237 (* Convert body to string for sending *) 238 let request_body_str = match body with 239 | None -> "" 240 | Some b -> Body.Private.to_string b 241 in 242 243 let response = 244 245 (* Execute request with redirect handling *) 246 let rec make_with_redirects url_to_fetch redirects_left = 247 let uri_to_fetch = Uri.of_string url_to_fetch in 248 249 (* Parse the redirect URL to get correct host and port *) 250 let redirect_host = match Uri.host uri_to_fetch with 251 | Some h -> h 252 | None -> failwith "Redirect URL must contain a host" 253 in 254 let redirect_port = match Uri.scheme uri_to_fetch, Uri.port uri_to_fetch with 255 | Some "https", None -> 443 256 | Some "https", Some p -> p 257 | Some "http", None -> 80 258 | Some "http", Some p -> p 259 | _, Some p -> p 260 | _ -> 80 261 in 262 263 (* Create endpoint for this specific URL *) 264 let redirect_endpoint = Conpool.Endpoint.make ~host:redirect_host ~port:redirect_port in 265 266 (* Determine if we need TLS based on this URL's scheme *) 267 let redirect_is_https = match Uri.scheme uri_to_fetch with 268 | Some "https" -> true 269 | _ -> false 270 in 271 272 (* Choose the appropriate connection pool for this URL *) 273 let redirect_pool = if redirect_is_https then t.https_pool else t.http_pool in 274 275 let make_request_fn () = 276 Conpool.with_connection redirect_pool redirect_endpoint (fun flow -> 277 (* Flow is already TLS-wrapped if from https_pool, plain TCP if from http_pool *) 278 (* Use our low-level HTTP client *) 279 Http_client.make_request ~method_:method_str ~uri:uri_to_fetch 280 ~headers ~body_str:request_body_str flow 281 ) 282 in 283 284 (* Apply timeout if specified *) 285 let status, resp_headers, response_body_str = 286 let timeout_val = Option.value timeout ~default:t.timeout in 287 match Timeout.total timeout_val with 288 | Some seconds -> 289 Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds); 290 Eio.Time.with_timeout_exn t.clock seconds make_request_fn 291 | None -> make_request_fn () 292 in 293 294 Log.info (fun m -> m "Received response: status=%d" status); 295 296 (* Handle redirects if enabled *) 297 let follow = Option.value follow_redirects ~default:t.follow_redirects in 298 let max_redir = Option.value max_redirects ~default:t.max_redirects in 299 300 if follow && (status >= 300 && status < 400) then begin 301 if redirects_left <= 0 then begin 302 Log.err (fun m -> m "Too many redirects (%d) for %s" max_redir url); 303 raise (Error.TooManyRedirects { url; count = max_redir; max = max_redir }) 304 end; 305 306 match Headers.get "location" resp_headers with 307 | None -> 308 Log.debug (fun m -> m "Redirect response missing Location header"); 309 (status, resp_headers, response_body_str, url_to_fetch) 310 | Some location -> 311 Log.info (fun m -> m "Following redirect to %s (%d remaining)" location redirects_left); 312 make_with_redirects location (redirects_left - 1) 313 end else 314 (status, resp_headers, response_body_str, url_to_fetch) 315 in 316 317 let max_redir = Option.value max_redirects ~default:t.max_redirects in 318 let final_status, final_headers, final_body_str, final_url = 319 make_with_redirects url max_redir 320 in 321 322 let elapsed = Unix.gettimeofday () -. start_time in 323 Log.info (fun m -> m "Request completed in %.3f seconds" elapsed); 324 325 (* Create a flow from the body string *) 326 let body_flow = Eio.Flow.string_source final_body_str in 327 328 Response.Private.make 329 ~sw:t.sw 330 ~status:final_status 331 ~headers:final_headers 332 ~body:body_flow 333 ~url:final_url 334 ~elapsed 335 in 336 337 (* Extract and store cookies from response *) 338 let () = 339 Eio.Mutex.use_rw ~protect:true t.cookie_mutex (fun () -> 340 match Response.headers response |> Headers.get_all "Set-Cookie" with 341 | [] -> () 342 | cookie_headers -> 343 Log.debug (fun m -> m "Received %d Set-Cookie headers" (List.length cookie_headers)); 344 List.iter (fun cookie_str -> 345 match Cookeio.parse_set_cookie ~domain ~path cookie_str with 346 | Some cookie -> 347 Log.debug (fun m -> m "Storing cookie"); 348 Cookeio.add_cookie t.cookie_jar cookie 349 | None -> 350 Log.warn (fun m -> m "Failed to parse cookie: %s" cookie_str) 351 ) cookie_headers 352 ) 353 in 354 355 (* Update statistics *) 356 t.requests_made <- t.requests_made + 1; 357 t.total_time <- t.total_time +. (Unix.gettimeofday () -. start_time); 358 Log.info (fun m -> m "Request completed with status %d" (Response.status_code response)); 359 360 response 361 362(* Public request function - executes synchronously *) 363let request t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url = 364 make_request_internal t ?headers ?body ?auth ?timeout 365 ?follow_redirects ?max_redirects ~method_ url 366 367(* Convenience methods *) 368let get t ?headers ?auth ?timeout ?params url = 369 let url = match params with 370 | Some p -> 371 let uri = Uri.of_string url in 372 let uri = List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v)) uri p in 373 Uri.to_string uri 374 | None -> url 375 in 376 request t ?headers ?auth ?timeout ~method_:`GET url 377 378let post t ?headers ?body ?auth ?timeout url = 379 request t ?headers ?body ?auth ?timeout ~method_:`POST url 380 381let put t ?headers ?body ?auth ?timeout url = 382 request t ?headers ?body ?auth ?timeout ~method_:`PUT url 383 384let patch t ?headers ?body ?auth ?timeout url = 385 request t ?headers ?body ?auth ?timeout ~method_:`PATCH url 386 387let delete t ?headers ?auth ?timeout url = 388 request t ?headers ?auth ?timeout ~method_:`DELETE url 389 390let head t ?headers ?auth ?timeout url = 391 request t ?headers ?auth ?timeout ~method_:`HEAD url 392 393let options t ?headers ?auth ?timeout url = 394 request t ?headers ?auth ?timeout ~method_:`OPTIONS url 395 396(* Cmdliner integration module *) 397module Cmd = struct 398 open Cmdliner 399 400 type config = { 401 xdg : Xdge.t * Xdge.Cmd.t; 402 persist_cookies : bool; 403 verify_tls : bool; 404 timeout : float option; 405 max_retries : int; 406 retry_backoff : float; 407 follow_redirects : bool; 408 max_redirects : int; 409 user_agent : string option; 410 } 411 412 let create config env sw = 413 let xdg, _xdg_cmd = config.xdg in 414 let retry = if config.max_retries > 0 then 415 Some (Retry.create_config 416 ~max_retries:config.max_retries 417 ~backoff_factor:config.retry_backoff ()) 418 else None in 419 420 let timeout = match config.timeout with 421 | Some t -> Timeout.create ~total:t () 422 | None -> Timeout.default in 423 424 let req = create ~sw 425 ~xdg 426 ~persist_cookies:config.persist_cookies 427 ~verify_tls:config.verify_tls 428 ~timeout 429 ?retry 430 ~follow_redirects:config.follow_redirects 431 ~max_redirects:config.max_redirects 432 env in 433 434 (* Set user agent if provided *) 435 let req = match config.user_agent with 436 | Some ua -> set_default_header req "User-Agent" ua 437 | None -> req 438 in 439 440 req 441 442 (* Individual terms - parameterized by app_name *) 443 444 let persist_cookies_term app_name = 445 let doc = "Persist cookies to disk between sessions" in 446 let env_name = String.uppercase_ascii app_name ^ "_PERSIST_COOKIES" in 447 let env_info = Cmdliner.Cmd.Env.info env_name in 448 Arg.(value & flag & info ["persist-cookies"] ~env:env_info ~doc) 449 450 let verify_tls_term app_name = 451 let doc = "Skip TLS certificate verification (insecure)" in 452 let env_name = String.uppercase_ascii app_name ^ "_NO_VERIFY_TLS" in 453 let env_info = Cmdliner.Cmd.Env.info env_name in 454 Term.(const (fun no_verify -> not no_verify) $ 455 Arg.(value & flag & info ["no-verify-tls"] ~env:env_info ~doc)) 456 457 let timeout_term app_name = 458 let doc = "Request timeout in seconds" in 459 let env_name = String.uppercase_ascii app_name ^ "_TIMEOUT" in 460 let env_info = Cmdliner.Cmd.Env.info env_name in 461 Arg.(value & opt (some float) None & info ["timeout"] ~env:env_info ~docv:"SECONDS" ~doc) 462 463 let retries_term app_name = 464 let doc = "Maximum number of request retries" in 465 let env_name = String.uppercase_ascii app_name ^ "_MAX_RETRIES" in 466 let env_info = Cmdliner.Cmd.Env.info env_name in 467 Arg.(value & opt int 3 & info ["max-retries"] ~env:env_info ~docv:"N" ~doc) 468 469 let retry_backoff_term app_name = 470 let doc = "Retry backoff factor for exponential delay" in 471 let env_name = String.uppercase_ascii app_name ^ "_RETRY_BACKOFF" in 472 let env_info = Cmdliner.Cmd.Env.info env_name in 473 Arg.(value & opt float 0.3 & info ["retry-backoff"] ~env:env_info ~docv:"FACTOR" ~doc) 474 475 let follow_redirects_term app_name = 476 let doc = "Don't follow HTTP redirects" in 477 let env_name = String.uppercase_ascii app_name ^ "_NO_FOLLOW_REDIRECTS" in 478 let env_info = Cmdliner.Cmd.Env.info env_name in 479 Term.(const (fun no_follow -> not no_follow) $ 480 Arg.(value & flag & info ["no-follow-redirects"] ~env:env_info ~doc)) 481 482 let max_redirects_term app_name = 483 let doc = "Maximum number of redirects to follow" in 484 let env_name = String.uppercase_ascii app_name ^ "_MAX_REDIRECTS" in 485 let env_info = Cmdliner.Cmd.Env.info env_name in 486 Arg.(value & opt int 10 & info ["max-redirects"] ~env:env_info ~docv:"N" ~doc) 487 488 let user_agent_term app_name = 489 let doc = "User-Agent header to send with requests" in 490 let env_name = String.uppercase_ascii app_name ^ "_USER_AGENT" in 491 let env_info = Cmdliner.Cmd.Env.info env_name in 492 Arg.(value & opt (some string) None & info ["user-agent"] ~env:env_info ~docv:"STRING" ~doc) 493 494 (* Combined terms *) 495 496 let config_term app_name fs = 497 let xdg_term = Xdge.Cmd.term app_name fs 498 ~dirs:[`Config; `Data; `Cache] () in 499 Term.(const (fun xdg persist verify timeout retries backoff follow max_redir ua -> 500 { xdg; persist_cookies = persist; verify_tls = verify; 501 timeout; max_retries = retries; retry_backoff = backoff; 502 follow_redirects = follow; max_redirects = max_redir; 503 user_agent = ua }) 504 $ xdg_term 505 $ persist_cookies_term app_name 506 $ verify_tls_term app_name 507 $ timeout_term app_name 508 $ retries_term app_name 509 $ retry_backoff_term app_name 510 $ follow_redirects_term app_name 511 $ max_redirects_term app_name 512 $ user_agent_term app_name) 513 514 let requests_term app_name eio_env sw = 515 let config_t = config_term app_name eio_env#fs in 516 Term.(const (fun config -> create config eio_env sw) $ config_t) 517 518 let minimal_term app_name fs = 519 let xdg_term = Xdge.Cmd.term app_name fs 520 ~dirs:[`Data; `Cache] () in 521 Term.(const (fun (xdg, _xdg_cmd) persist -> (xdg, persist)) 522 $ xdg_term 523 $ persist_cookies_term app_name) 524 525 let env_docs app_name = 526 let app_upper = String.uppercase_ascii app_name in 527 Printf.sprintf 528 "## ENVIRONMENT\n\n\ 529 The following environment variables affect %s:\n\n\ 530 **%s_CONFIG_DIR**\n\ 531 : Override configuration directory location\n\n\ 532 **%s_DATA_DIR**\n\ 533 : Override data directory location (for cookies)\n\n\ 534 **%s_CACHE_DIR**\n\ 535 : Override cache directory location\n\n\ 536 **XDG_CONFIG_HOME**\n\ 537 : Base directory for user configuration files (default: ~/.config)\n\n\ 538 **XDG_DATA_HOME**\n\ 539 : Base directory for user data files (default: ~/.local/share)\n\n\ 540 **XDG_CACHE_HOME**\n\ 541 : Base directory for user cache files (default: ~/.cache)\n\n\ 542 **%s_PERSIST_COOKIES**\n\ 543 : Set to '1' to persist cookies by default\n\n\ 544 **%s_NO_VERIFY_TLS**\n\ 545 : Set to '1' to disable TLS verification (insecure)\n\n\ 546 **%s_TIMEOUT**\n\ 547 : Default request timeout in seconds\n\n\ 548 **%s_MAX_RETRIES**\n\ 549 : Maximum number of retries (default: 3)\n\n\ 550 **%s_RETRY_BACKOFF**\n\ 551 : Retry backoff factor (default: 0.3)\n\n\ 552 **%s_NO_FOLLOW_REDIRECTS**\n\ 553 : Set to '1' to disable redirect following\n\n\ 554 **%s_MAX_REDIRECTS**\n\ 555 : Maximum redirects to follow (default: 10)\n\n\ 556 **%s_USER_AGENT**\n\ 557 : User-Agent header to send with requests\ 558 " 559 app_name app_upper app_upper app_upper 560 app_upper app_upper app_upper app_upper 561 app_upper app_upper app_upper app_upper 562 563 let pp_config ppf config = 564 let _xdg, xdg_cmd = config.xdg in 565 Format.fprintf ppf "@[<v>Configuration:@,\ 566 @[<v 2>XDG:@,%a@]@,\ 567 persist_cookies: %b@,\ 568 verify_tls: %b@,\ 569 timeout: %a@,\ 570 max_retries: %d@,\ 571 retry_backoff: %.2f@,\ 572 follow_redirects: %b@,\ 573 max_redirects: %d@,\ 574 user_agent: %a@]" 575 Xdge.Cmd.pp xdg_cmd 576 config.persist_cookies 577 config.verify_tls 578 (Format.pp_print_option Format.pp_print_float) config.timeout 579 config.max_retries 580 config.retry_backoff 581 config.follow_redirects 582 config.max_redirects 583 (Format.pp_print_option Format.pp_print_string) config.user_agent 584end