forked from
anil.recoil.org/ocaml-requests
A batteries included HTTP/1.1 client in OCaml
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6(** OCaml HTTP client library with streaming support *)
7
8let src = Logs.Src.create "requests" ~doc:"HTTP Client Library"
9
10module Log = (val Logs.src_log src : Logs.LOG)
11module Method = Method
12module Mime = Mime
13module Headers = Headers
14module Http_date = Http_date
15module Http_version = Http_version
16module Auth = Auth
17module Proxy = Proxy
18module Proxy_tunnel = Proxy_tunnel
19module Timeout = Timeout
20module Body = Body
21module Response = Response
22module One = One
23module Http_client = Http_client
24module Status = Status
25module Error = Error
26module Retry = Retry
27module Cache_control = Cache_control
28module Response_limits = Response_limits
29module Expect_continue = Expect_continue
30module Version = Version
31module Link = Link
32module Timing = Timing
33module Header_name = Header_name
34module Header_parsing = Header_parsing
35module Websocket = Websocket
36module Signature = Signature
37
38(** Minimum TLS version configuration - re-exported from Tls_config. *)
39type tls_version = Tls_config.tls_version =
40 | TLS_1_2 (** TLS 1.2 minimum (default, widely compatible) *)
41 | TLS_1_3
42 (** TLS 1.3 minimum (most secure, may not work with older servers) *)
43
44(* Main API - Session functionality with connection pooling *)
45
46(** Protocol hint for endpoints - remembers ALPN negotiation results. *)
47type protocol_hint = H1 | H2
48
49type t =
50 | T : {
51 sw : Eio.Switch.t;
52 clock : [> float Eio.Time.clock_ty ] Eio.Resource.t;
53 net : [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t;
54 http_pool : unit Conpool.t;
55 (** HTTP/1.x pool - exclusive access, no protocol state *)
56 https_pool : unit Conpool.t;
57 (** HTTPS pool - exclusive access, no protocol state *)
58 h2_pool : H2_conpool_handler.h2_state Conpool.t;
59 (** HTTP/2 pool - shared access with H2 client state *)
60 protocol_hints : (string, protocol_hint) Hashtbl.t;
61 (** Maps "host:port" to protocol hint from ALPN *)
62 protocol_hints_mutex : Eio.Mutex.t;
63 cookie_jar : Cookeio_jar.t;
64 cookie_mutex : Eio.Mutex.t;
65 default_headers : Headers.t;
66 auth : Auth.t option;
67 timeout : Timeout.t;
68 follow_redirects : bool;
69 max_redirects : int;
70 verify_tls : bool;
71 tls_config : Tls.Config.client option;
72 retry : Retry.config option;
73 persist_cookies : bool;
74 xdg : Xdge.t option;
75 auto_decompress : bool;
76 expect_100_continue : Expect_continue.t;
77 (** 100-continue configuration *)
78 base_url : string option;
79 (** Per Recommendation #11: Base URL for relative paths *)
80 xsrf_cookie_name : string option;
81 (** Per Recommendation #24: XSRF cookie name *)
82 xsrf_header_name : string;
83 (** Per Recommendation #24: XSRF header name *)
84 proxy : Proxy.config option; (** HTTP/HTTPS proxy configuration *)
85 allow_insecure_auth : bool; (** Allow auth over HTTP for dev/testing *)
86 nonce_counter : Auth.Nonce_counter.t;
87 (** Digest auth nonce count tracker *)
88 (* Statistics - mutable but NOTE: when sessions are derived via record update
89 syntax ({t with field = value}), these are copied not shared. Each derived
90 session has independent statistics. Use the same session object to track
91 cumulative stats. *)
92 mutable requests_made : int;
93 mutable total_time : float;
94 mutable retries_count : int;
95 }
96 -> t
97
98let connection_pools ~sw ~net ~clock ?http_pool ?https_pool ~tls_config
99 ~max_connections_per_host ~connection_idle_timeout ~connection_lifetime () =
100 let pool_config =
101 Conpool.Config.v ~max_connections_per_endpoint:max_connections_per_host
102 ~max_idle_time:connection_idle_timeout
103 ~max_connection_lifetime:connection_lifetime ()
104 in
105 let http_pool =
106 match http_pool with
107 | Some p -> p
108 | None -> Conpool.basic ~sw ~net ~clock ~config:pool_config ()
109 in
110 let https_pool =
111 match https_pool with
112 | Some p -> p
113 | None ->
114 Conpool.basic ~sw ~net ~clock ?tls:tls_config ~config:pool_config ()
115 in
116 let h2_pool =
117 Conpool.v ~sw ~net ~clock ?tls:tls_config ~config:pool_config
118 ~protocol:H2_conpool_handler.h2_protocol ()
119 in
120 (http_pool, https_pool, h2_pool)
121
122let resolve_cookie_jar ~cookie_jar ~persist_cookies ~xdg ~clock =
123 match (cookie_jar, persist_cookies, xdg) with
124 | Some jar, _, _ -> jar
125 | None, true, Some xdg_ctx ->
126 let data_dir = Xdge.data_dir xdg_ctx in
127 let cookie_file = Eio.Path.(data_dir / "cookies.txt") in
128 Cookeio_jar.load ~clock cookie_file
129 | None, _, _ -> Cookeio_jar.v ()
130
131let normalize_base_url base_url =
132 Option.map
133 (fun url ->
134 if String.length url > 0 && url.[String.length url - 1] = '/' then
135 String.sub url 0 (String.length url - 1)
136 else url)
137 base_url
138
139let resolve_xdg ~xdg ~persist_cookies env_fs =
140 match (xdg, persist_cookies) with
141 | Some x, _ -> Some x
142 | None, true -> Some (Xdge.v env_fs "requests")
143 | None, false -> None
144
145let build_expect_100_config ~expect_100_continue ~timeout =
146 let expect_100_timeout =
147 Timeout.expect_100_continue timeout |> Option.value ~default:1.0
148 in
149 Expect_continue.of_config ~timeout:expect_100_timeout expect_100_continue
150
151let session ~sw ~clock ~net ~http_pool ~https_pool ~h2_pool ~protocol_hints
152 ~cookie_jar ~default_headers ~auth ~timeout ~follow_redirects ~max_redirects
153 ~verify_tls ~tls_config ~retry ~persist_cookies ~xdg ~auto_decompress
154 ~expect_100_config ~base_url ~xsrf_cookie_name ~xsrf_header_name ~proxy
155 ~allow_insecure_auth =
156 T
157 {
158 sw;
159 clock;
160 net;
161 http_pool;
162 https_pool;
163 h2_pool;
164 protocol_hints;
165 protocol_hints_mutex = Eio.Mutex.create ();
166 cookie_jar;
167 cookie_mutex = Eio.Mutex.create ();
168 default_headers;
169 auth;
170 timeout;
171 follow_redirects;
172 max_redirects;
173 verify_tls;
174 tls_config;
175 retry;
176 persist_cookies;
177 xdg;
178 auto_decompress;
179 expect_100_continue = expect_100_config;
180 base_url;
181 xsrf_cookie_name;
182 xsrf_header_name;
183 proxy;
184 allow_insecure_auth;
185 nonce_counter = Auth.Nonce_counter.create ();
186 requests_made = 0;
187 total_time = 0.0;
188 retries_count = 0;
189 }
190
191let v ~sw ?http_pool ?https_pool ?cookie_jar ?(default_headers = Headers.empty)
192 ?auth ?(timeout = Timeout.default) ?(follow_redirects = true)
193 ?(max_redirects = 10) ?(verify_tls = true) ?tls_config
194 ?(min_tls_version = TLS_1_2) ?(max_connections_per_host = 10)
195 ?(connection_idle_timeout = 60.0) ?(connection_lifetime = 300.0) ?retry
196 ?(persist_cookies = false) ?xdg ?(auto_decompress = true)
197 ?(expect_100_continue = `Threshold Expect_continue.default_threshold)
198 ?base_url ?(xsrf_cookie_name = Some "XSRF-TOKEN")
199 (* Per Recommendation #24 *) ?(xsrf_header_name = "X-XSRF-TOKEN") ?proxy
200 ?(allow_insecure_auth = false) env =
201 Crypto_rng_unix.use_default ();
202 (* avsm: is this bad to do twice? very common footgun to forget to initialise *)
203 let clock = env#clock in
204 let net = env#net in
205 let xdg = resolve_xdg ~xdg ~persist_cookies env#fs in
206 (* Create TLS config for HTTPS pool if needed
207 Per Recommendation #6: Enforce minimum TLS version *)
208 let tls_config =
209 Tls_config.client_opt ?existing_config:tls_config ~verify_tls
210 ~min_tls_version ~host:"session-init" ()
211 in
212 let http_pool, https_pool, h2_pool =
213 connection_pools ~sw ~net ~clock ?http_pool ?https_pool ~tls_config
214 ~max_connections_per_host ~connection_idle_timeout ~connection_lifetime ()
215 in
216 let protocol_hints = Hashtbl.create 32 in
217 Log.info (fun m ->
218 m
219 "Created Requests session with connection pools (max_per_host=%d, \
220 TLS=%b)"
221 max_connections_per_host
222 (Option.is_some tls_config));
223 let cookie_jar =
224 resolve_cookie_jar ~cookie_jar ~persist_cookies ~xdg ~clock
225 in
226 let expect_100_config =
227 build_expect_100_config ~expect_100_continue ~timeout
228 in
229 let base_url = normalize_base_url base_url in
230 session ~sw ~clock ~net ~http_pool ~https_pool ~h2_pool ~protocol_hints
231 ~cookie_jar ~default_headers ~auth ~timeout ~follow_redirects ~max_redirects
232 ~verify_tls ~tls_config ~retry ~persist_cookies ~xdg ~auto_decompress
233 ~expect_100_config ~base_url ~xsrf_cookie_name ~xsrf_header_name ~proxy
234 ~allow_insecure_auth
235
236let set_default_header (T t) key value =
237 T
238 {
239 t with
240 default_headers =
241 Headers.set (Header_name.of_string key) value t.default_headers;
242 }
243
244let remove_default_header (T t) key =
245 T
246 {
247 t with
248 default_headers =
249 Headers.remove (Header_name.of_string key) t.default_headers;
250 }
251
252let set_auth (T t) auth =
253 Log.debug (fun m -> m "Setting authentication method");
254 T { t with auth = Some auth }
255
256let clear_auth (T t) =
257 Log.debug (fun m -> m "Clearing authentication");
258 T { t with auth = None }
259
260let set_timeout (T t) timeout =
261 Log.debug (fun m -> m "Setting timeout: %a" Timeout.pp timeout);
262 T { t with timeout }
263
264let set_retry (T t) config =
265 Log.debug (fun m ->
266 m "Setting retry config: max_retries=%d" config.Retry.max_retries);
267 T { t with retry = Some config }
268
269let cookies (T t) = t.cookie_jar
270let clear_cookies (T t) = Cookeio_jar.clear t.cookie_jar
271
272let set_proxy (T t) config =
273 Log.debug (fun m ->
274 m "Setting proxy: %s:%d" config.Proxy.host config.Proxy.port);
275 T { t with proxy = Some config }
276
277let clear_proxy (T t) =
278 Log.debug (fun m -> m "Clearing proxy configuration");
279 T { t with proxy = None }
280
281let proxy (T t) = t.proxy
282
283(* Redirect handling - delegated to shared Redirect module *)
284
285(** {1 URL Resolution and Path Templating}
286
287 Per Recommendation #11: Base URL support with RFC 3986 resolution. Per
288 Recommendation #29: Path parameter templating. *)
289
290(** Check if a URL is relative (no scheme) *)
291let is_relative_url url =
292 let uri = Uri.of_string url in
293 Option.is_none (Uri.scheme uri)
294
295(** Resolve a URL against a base URL per RFC 3986 Section 5. If the URL is
296 already absolute, return it unchanged. If base_url is None, return the
297 original URL. *)
298let resolve_url ?base_url url =
299 match base_url with
300 | None -> url
301 | Some base ->
302 if is_relative_url url then begin
303 let base_uri = Uri.of_string base in
304 let rel_uri = Uri.of_string url in
305 let scheme = Uri.scheme base_uri |> Option.value ~default:"https" in
306 let resolved = Uri.resolve scheme base_uri rel_uri in
307 Log.debug (fun m ->
308 m "Resolved relative URL %s against base %s -> %s" url base
309 (Uri.to_string resolved));
310 Uri.to_string resolved
311 end
312 else url (* Already absolute *)
313
314(** Substitute path parameters in a URL template. Per Recommendation #29 and RFC
315 6570 (simplified). Template: ["/users/\{id\}/posts/\{post_id\}"] Params:
316 [[("id", "123"); ("post_id", "456")]] Result: ["/users/123/posts/456"]
317 Values are automatically URL-encoded. *)
318let substitute_path_params url params =
319 List.fold_left
320 (fun url (key, value) ->
321 let pattern = "{" ^ key ^ "}" in
322 let encoded_value = Uri.pct_encode value in
323 let rec replace s =
324 match String.split_on_char '{' s with
325 | [] -> ""
326 | [ single ] -> single
327 | before :: rest ->
328 let rest_str = String.concat "{" rest in
329 if
330 String.length rest_str >= String.length key + 1
331 && String.sub rest_str 0 (String.length key) = key
332 && rest_str.[String.length key] = '}'
333 then
334 before ^ encoded_value
335 ^ String.sub rest_str
336 (String.length key + 1)
337 (String.length rest_str - String.length key - 1)
338 else before ^ "{" ^ replace rest_str
339 in
340 if String.length pattern > 0 then replace url else url)
341 url params
342
343(** {1 XSRF Token Handling}
344
345 Per Recommendation #24: Automatically inject XSRF tokens from cookies. *)
346
347(** Extract XSRF token from cookies and add to headers if: 1. xsrf_cookie_name
348 is configured 2. The cookie exists 3. The request is same-origin (security)
349*)
350let apply_xsrf_token ~cookie_jar ~clock ~xsrf_cookie_name ~xsrf_header_name ~url
351 headers =
352 match xsrf_cookie_name with
353 | None -> headers (* XSRF handling disabled *)
354 | Some cookie_name -> (
355 let uri = Uri.of_string url in
356 let domain = Uri.host uri |> Option.value ~default:"" in
357 let path = Uri.path uri in
358 let is_secure = Uri.scheme uri = Some "https" in
359
360 (* Get cookies for this domain *)
361 let cookies =
362 Cookeio_jar.cookies cookie_jar ~clock ~domain ~path ~is_secure
363 in
364
365 (* Find the XSRF token cookie *)
366 let xsrf_value =
367 List.find_map
368 (fun cookie ->
369 if Cookeio.name cookie = cookie_name then
370 Some (Cookeio.value cookie)
371 else None)
372 cookies
373 in
374
375 match xsrf_value with
376 | Some token ->
377 Log.debug (fun m -> m "Adding XSRF token header: %s" xsrf_header_name);
378 (* XSRF header name is configurable, use string variant *)
379 Headers.set_string xsrf_header_name token headers
380 | None -> headers)
381
382let h2_conn_response ~sw ~h2_pool ~endpoint ~uri ~headers ~body ~method_
383 ~auto_decompress =
384 Eio.Switch.run (fun conn_sw ->
385 let h2_conn = Conpool.connection ~sw:conn_sw h2_pool endpoint in
386 match
387 H2_conpool_handler.request ~state:h2_conn.Conpool.state ~uri ~headers
388 ~body ~method_ ~auto_decompress ()
389 with
390 | Ok resp ->
391 (resp.H2_adapter.status, resp.H2_adapter.headers, resp.H2_adapter.body)
392 | Error msg ->
393 raise
394 (Error.err
395 (Error.Invalid_request { reason = "HTTP/2 error: " ^ msg })))
396
397let h2_alpn_response ~sw ~flow ~uri ~headers ~body ~method_ ~auto_decompress =
398 match
399 H2_adapter.request ~sw ~flow ~uri ~headers ~body ~method_ ~auto_decompress
400 ()
401 with
402 | Ok resp ->
403 (resp.H2_adapter.status, resp.H2_adapter.headers, resp.H2_adapter.body)
404 | Error msg ->
405 raise
406 (Error.err (Error.Invalid_request { reason = "HTTP/2 error: " ^ msg }))
407
408let pooled_h1_request ~outer_sw ~clock ~pool ~endpoint ~uri ~headers ~body
409 ~method_ ~auto_decompress ~expect_100 =
410 Eio.Switch.run (fun conn_sw ->
411 let conn_info = Conpool.connection ~sw:conn_sw pool endpoint in
412 Http_client.request_100_continue_decompress ~expect_100 ~clock
413 ~sw:outer_sw ~method_ ~uri ~headers ~body ~auto_decompress
414 conn_info.Conpool.flow)
415
416let pooled_with_h1_request ~outer_sw ~clock ~pool ~endpoint ~uri ~headers ~body
417 ~method_ ~auto_decompress ~expect_100 =
418 Conpool.with_connection pool endpoint (fun conn ->
419 Http_client.request_100_continue_decompress ~expect_100 ~clock
420 ~sw:outer_sw ~method_ ~uri ~headers ~body ~auto_decompress
421 conn.Conpool.flow)
422
423let update_protocol_hint ~protocol_hints_mutex ~protocol_hints ~endpoint_key
424 ~is_h2 =
425 let hint = if is_h2 then H2 else H1 in
426 Eio.Mutex.use_rw ~protect:true protocol_hints_mutex (fun () ->
427 Hashtbl.replace protocol_hints endpoint_key hint);
428 Log.debug (fun m ->
429 m "Learned protocol for %s: %s" endpoint_key
430 (if is_h2 then "H2" else "H1"))
431
432let alpn_detect_request ~outer_sw ~clock ~pool ~endpoint ~uri ~headers ~body
433 ~method_ ~auto_decompress ~expect_100 ~is_https ~protocol_hints_mutex
434 ~protocol_hints ~endpoint_key ~url =
435 Eio.Switch.run (fun conn_sw ->
436 let conn_info = Conpool.connection ~sw:conn_sw pool endpoint in
437 let is_h2 =
438 match conn_info.Conpool.tls_epoch with
439 | Some epoch -> epoch.Tls.Core.alpn_protocol = Some "h2"
440 | None -> false
441 in
442 if is_https then
443 update_protocol_hint ~protocol_hints_mutex ~protocol_hints ~endpoint_key
444 ~is_h2;
445 if is_h2 then begin
446 Log.debug (fun m -> m "Using HTTP/2 for %s (ALPN negotiated)" url);
447 h2_alpn_response ~sw:conn_sw ~flow:conn_info.Conpool.flow ~uri ~headers
448 ~body ~method_ ~auto_decompress
449 end
450 else
451 Http_client.request_100_continue_decompress ~expect_100 ~clock
452 ~sw:outer_sw ~method_ ~uri ~headers ~body ~auto_decompress
453 conn_info.Conpool.flow)
454
455let proxy_http_request ~sw ~pool ~proxy_endpoint ~uri ~headers ~body ~method_
456 ~auto_decompress ~proxy_auth =
457 Conpool.with_connection pool proxy_endpoint (fun conn ->
458 Http_write.write_and_flush conn.Conpool.flow (fun w ->
459 Http_write.request_via_proxy w ~sw ~method_ ~uri ~headers ~body
460 ~proxy_auth);
461 let limits = Response_limits.default in
462 let buf_read = Http_read.of_flow ~max_size:65536 conn.Conpool.flow in
463 let _version, status, resp_headers, body_str =
464 Http_read.response ~limits ~method_ buf_read
465 in
466 let body_str =
467 match
468 (auto_decompress, Headers.find `Content_encoding resp_headers)
469 with
470 | true, Some encoding ->
471 Http_client.decompress_body ~limits ~content_encoding:encoding
472 body_str
473 | _ -> body_str
474 in
475 (status, resp_headers, body_str))
476
477let build_request_headers ~default_headers ~cookie_mutex ~cookie_jar ~clock
478 ~xsrf_cookie_name ~xsrf_header_name ~default_auth ~allow_insecure_auth
479 ~auto_decompress ~headers ~auth ~body ~url =
480 let headers =
481 match headers with
482 | Some h -> Headers.merge default_headers h
483 | None -> default_headers
484 in
485 let headers =
486 if not (Headers.mem `User_agent headers) then
487 Headers.set `User_agent Version.user_agent headers
488 else headers
489 in
490 let headers =
491 Eio.Mutex.use_ro cookie_mutex (fun () ->
492 apply_xsrf_token ~cookie_jar ~clock ~xsrf_cookie_name ~xsrf_header_name
493 ~url headers)
494 in
495 let auth = match auth with Some a -> Some a | None -> default_auth in
496 let headers =
497 match auth with
498 | Some a ->
499 Log.debug (fun m -> m "Applying authentication");
500 Auth.apply_secure ~allow_insecure_auth ~url a headers
501 | None -> headers
502 in
503 let headers =
504 match body with
505 | Some b -> (
506 match Body.content_type b with
507 | Some mime -> Headers.content_type mime headers
508 | None -> headers)
509 | None -> headers
510 in
511 let headers =
512 if auto_decompress && not (Headers.mem `Accept_encoding headers) then
513 Headers.set `Accept_encoding "gzip, deflate" headers
514 else headers
515 in
516 (auth, headers)
517
518let extract_url_components uri_to_fetch url_to_fetch =
519 let redirect_host =
520 match Uri.host uri_to_fetch with
521 | Some h -> h
522 | None ->
523 raise
524 (Error.err
525 (Error.Invalid_redirect
526 { url = url_to_fetch; reason = "URL must contain a host" }))
527 in
528 let redirect_port =
529 match (Uri.scheme uri_to_fetch, Uri.port uri_to_fetch) with
530 | Some "https", None -> 443
531 | Some "https", Some p -> p
532 | Some "http", None -> 80
533 | Some "http", Some p -> p
534 | _, Some p -> p
535 | _ -> 80
536 in
537 let redirect_is_https =
538 match Uri.scheme uri_to_fetch with Some "https" -> true | _ -> false
539 in
540 (redirect_host, redirect_port, redirect_is_https)
541
542let add_cookies_to_headers ~cookie_mutex ~cookie_jar ~clock ~headers_for_request
543 ~host ~path ~is_secure =
544 Eio.Mutex.use_ro cookie_mutex (fun () ->
545 let cookies =
546 Cookeio_jar.cookies cookie_jar ~clock ~domain:host ~path ~is_secure
547 in
548 match cookies with
549 | [] ->
550 Log.debug (fun m -> m "No cookies found for %s%s" host path);
551 headers_for_request
552 | cookies ->
553 let cookie_header = Cookeio.cookie_header cookies in
554 let cookie_summary =
555 cookies
556 |> List.map (fun c ->
557 Fmt.str "%s=<%d chars>" (Cookeio.name c)
558 (String.length (Cookeio.value c)))
559 |> String.concat "; "
560 in
561 Log.debug (fun m ->
562 m "Adding %d cookies for %s%s: [%s]" (List.length cookies) host
563 path cookie_summary);
564 Headers.set `Cookie cookie_header headers_for_request)
565
566(* Extract and store Set-Cookie headers from a response into the session cookie jar. *)
567let store_response_cookies (T t) ~resp_headers ~url_str =
568 let uri = Uri.of_string url_str in
569 let cookie_domain = Uri.host uri |> Option.value ~default:"" in
570 let cookie_path = Uri.path uri in
571 Eio.Mutex.use_rw ~protect:true t.cookie_mutex (fun () ->
572 match Headers.all `Set_cookie resp_headers with
573 | [] -> ()
574 | cookie_headers ->
575 Log.debug (fun m ->
576 m "Received %d Set-Cookie headers" (List.length cookie_headers));
577 List.iter
578 (fun cookie_str ->
579 let now =
580 fun () -> Ptime.of_float_s (Eio.Time.now t.clock) |> Option.get
581 in
582 match
583 Cookeio.of_set_cookie_header ~now ~domain:cookie_domain
584 ~path:cookie_path cookie_str
585 with
586 | Ok cookie ->
587 Log.debug (fun m ->
588 m "Storing cookie: %s" (Cookeio.name cookie));
589 Cookeio_jar.add_cookie t.cookie_jar cookie
590 | Error msg ->
591 let redacted =
592 match String.index_opt cookie_str '=' with
593 | Some i -> String.sub cookie_str 0 i ^ "=<redacted>"
594 | None -> "<malformed>"
595 in
596 Log.warn (fun m ->
597 m "Failed to parse cookie: %s (%s)" redacted msg))
598 cookie_headers)
599
600(* Dispatch a request using ALPN negotiation to detect H1/H2 protocol. *)
601let dispatch_alpn (T t) ~redirect_is_https ~redirect_pool ~redirect_endpoint
602 ~endpoint_key ~uri_to_fetch ~headers ~body ~method_ () =
603 alpn_detect_request ~outer_sw:t.sw ~clock:t.clock ~pool:redirect_pool
604 ~endpoint:redirect_endpoint ~uri:uri_to_fetch ~headers ~body ~method_
605 ~auto_decompress:t.auto_decompress ~expect_100:t.expect_100_continue
606 ~is_https:redirect_is_https ~protocol_hints_mutex:t.protocol_hints_mutex
607 ~protocol_hints:t.protocol_hints ~endpoint_key
608 ~url:(Uri.to_string uri_to_fetch)
609
610let proxy_auth_header proxy =
611 match proxy.Proxy.auth with
612 | Some auth ->
613 let auth_headers = Auth.apply auth Headers.empty in
614 Headers.find `Authorization auth_headers
615 | None -> None
616
617(* Dispatch a single HTTP request to the appropriate transport based on proxy
618 settings, protocol hints, and whether the target is HTTPS. *)
619let dispatch_request (T t) ~use_proxy ~redirect_is_https ~redirect_pool
620 ~redirect_endpoint ~redirect_host ~redirect_port ~endpoint_key ~uri_to_fetch
621 ~headers ~body ~method_ ~protocol_hint () =
622 match (use_proxy, redirect_is_https, t.proxy, protocol_hint) with
623 | false, true, _, Some H2 ->
624 (* Known HTTP/2 - use h2_pool with shared connections *)
625 Log.debug (fun m ->
626 m "Using HTTP/2 for %s (from protocol hint)"
627 (Uri.to_string uri_to_fetch));
628 h2_conn_response ~sw:t.sw ~h2_pool:t.h2_pool ~endpoint:redirect_endpoint
629 ~uri:uri_to_fetch ~headers ~body ~method_
630 ~auto_decompress:t.auto_decompress
631 | false, true, _, Some H1 ->
632 Log.debug (fun m ->
633 m "Using HTTP/1.1 for %s (from protocol hint)"
634 (Uri.to_string uri_to_fetch));
635 pooled_h1_request ~outer_sw:t.sw ~clock:t.clock ~pool:redirect_pool
636 ~endpoint:redirect_endpoint ~uri:uri_to_fetch ~headers ~body ~method_
637 ~auto_decompress:t.auto_decompress ~expect_100:t.expect_100_continue
638 | false, _, _, _ ->
639 dispatch_alpn (T t) ~redirect_is_https ~redirect_pool ~redirect_endpoint
640 ~endpoint_key ~uri_to_fetch ~headers ~body ~method_ ()
641 | true, false, Some proxy, _ ->
642 (* HTTP via proxy - connect to proxy and use absolute-URI form *)
643 Log.debug (fun m ->
644 m "Routing HTTP request via proxy %s:%d" proxy.Proxy.host
645 proxy.Proxy.port);
646 let proxy_endpoint =
647 Conpool.Endpoint.v ~host:proxy.Proxy.host ~port:proxy.Proxy.port
648 in
649 (* Convert Auth.t to header value string *)
650 let proxy_auth = proxy_auth_header proxy in
651 proxy_http_request ~sw:t.sw ~pool:t.http_pool ~proxy_endpoint
652 ~uri:uri_to_fetch ~headers ~body ~method_
653 ~auto_decompress:t.auto_decompress ~proxy_auth
654 | true, true, Some proxy, _ ->
655 (* HTTPS via proxy - establish CONNECT tunnel then TLS *)
656 Log.debug (fun m ->
657 m "Routing HTTPS request via proxy %s:%d (CONNECT tunnel)"
658 proxy.Proxy.host proxy.Proxy.port);
659 (* Establish TLS tunnel through proxy *)
660 let tunnel_flow =
661 Proxy_tunnel.connect_with_tls ~sw:t.sw ~net:t.net ~clock:t.clock ~proxy
662 ~target_host:redirect_host ~target_port:redirect_port
663 ?tls_config:t.tls_config ()
664 in
665 (* Send request through tunnel using normal format (not absolute-URI) *)
666 Http_client.request_100_continue_decompress
667 ~expect_100:t.expect_100_continue ~clock:t.clock ~sw:t.sw ~method_
668 ~uri:uri_to_fetch ~headers ~body ~auto_decompress:t.auto_decompress
669 tunnel_flow
670 | true, _, None, _ ->
671 (* Should not happen due to use_proxy check *)
672 Conpool.with_connection redirect_pool redirect_endpoint (fun conn ->
673 Http_client.request_100_continue_decompress
674 ~expect_100:t.expect_100_continue ~clock:t.clock ~sw:t.sw ~method_
675 ~uri:uri_to_fetch ~headers ~body ~auto_decompress:t.auto_decompress
676 conn.Conpool.flow)
677
678let log_request_headers ~method_str ~uri ~headers =
679 Log.info (fun m -> m "");
680 Log.info (fun m -> m "=== Request to %s ===" (Uri.to_string uri));
681 Log.info (fun m -> m "> %s %s HTTP/1.1" method_str (Uri.to_string uri));
682 Log.info (fun m -> m "> Request Headers:");
683 Headers.to_list headers
684 |> List.iter (fun (k, v) ->
685 let v =
686 if Error.is_sensitive_header k then
687 Fmt.str "<redacted: %d chars>" (String.length v)
688 else v
689 in
690 Log.info (fun m -> m "> %s: %s" k v));
691 Log.info (fun m -> m "")
692
693let log_response_headers ~status ~resp_headers =
694 Log.info (fun m -> m "< HTTP/1.1 %d" status);
695 Log.info (fun m -> m "< Response Headers:");
696 Headers.to_list resp_headers
697 |> List.iter (fun (k, v) ->
698 let v =
699 if Error.is_sensitive_header k then
700 Fmt.str "<redacted: %d chars>" (String.length v)
701 else v
702 in
703 Log.info (fun m -> m "< %s: %s" k v));
704 Log.info (fun m -> m "")
705
706let execute_request_with_timeout (T t as wrapped_t) ~use_proxy
707 ~redirect_is_https ~redirect_pool ~redirect_endpoint ~redirect_host
708 ~redirect_port ~endpoint_key ~uri_to_fetch ~headers ~body ~method_
709 ~protocol_hint ~timeout =
710 let timeout_val = Option.value timeout ~default:t.timeout in
711 match Timeout.total timeout_val with
712 | Some seconds ->
713 Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds);
714 Eio.Time.with_timeout_exn t.clock seconds
715 (dispatch_request wrapped_t ~use_proxy ~redirect_is_https ~redirect_pool
716 ~redirect_endpoint ~redirect_host ~redirect_port ~endpoint_key
717 ~uri_to_fetch ~headers ~body ~method_ ~protocol_hint)
718 | None ->
719 dispatch_request wrapped_t ~use_proxy ~redirect_is_https ~redirect_pool
720 ~redirect_endpoint ~redirect_host ~redirect_port ~endpoint_key
721 ~uri_to_fetch ~headers ~body ~method_ ~protocol_hint ()
722
723let resolve_absolute_location ~base_uri location =
724 let location_uri = Uri.of_string location in
725 match Uri.host location_uri with
726 | Some _ -> location
727 | None ->
728 let scheme = Option.value (Uri.scheme base_uri) ~default:"http" in
729 let resolved = Uri.resolve scheme base_uri location_uri in
730 Uri.to_string resolved
731
732let redirect_headers_for_origin ~original_uri ~redirect_uri headers =
733 if Redirect.same_origin original_uri redirect_uri then headers
734 else begin
735 Log.debug (fun m ->
736 m "Cross-origin redirect detected: stripping sensitive headers");
737 Redirect.strip_sensitive_headers headers
738 end
739
740let rec handle_redirect_or_return wrapped_t ~original_url ~original_uri
741 ~follow_redirects ~max_redirects ~timeout ~headers_for_request ~method_
742 ~body ~url_to_fetch ~uri_to_fetch ~redirects_left status resp_headers
743 response_body_str =
744 let (T t) = wrapped_t in
745 let follow = Option.value follow_redirects ~default:t.follow_redirects in
746 let max_redir = Option.value max_redirects ~default:t.max_redirects in
747 if follow && status >= 300 && status < 400 then begin
748 if redirects_left <= 0 then begin
749 Log.err (fun m ->
750 m "Too many redirects (%d) for %s" max_redir original_url);
751 raise
752 (Error.err
753 (Error.Too_many_redirects
754 { url = original_url; count = max_redir; max = max_redir }))
755 end;
756 match Headers.find `Location resp_headers with
757 | None ->
758 Log.debug (fun m -> m "Redirect response missing Location header");
759 (status, resp_headers, response_body_str, url_to_fetch)
760 | Some location ->
761 let _ = Redirect.validate_url location in
762 let absolute_location =
763 resolve_absolute_location ~base_uri:uri_to_fetch location
764 in
765 Log.info (fun m ->
766 m "Following redirect to %s (%d remaining)" absolute_location
767 redirects_left);
768 let redirect_uri = Uri.of_string absolute_location in
769 let headers_for_redirect =
770 redirect_headers_for_origin ~original_uri ~redirect_uri
771 headers_for_request
772 in
773 let redirect_method, redirect_body =
774 if status = 303 then begin
775 match method_ with
776 | `POST | `PUT | `DELETE | `PATCH ->
777 Log.debug (fun m ->
778 m "303 redirect: changing %s to GET and stripping body"
779 (Method.to_string method_));
780 (`GET, Body.empty)
781 | _ -> (method_, body)
782 end
783 else (method_, body)
784 in
785 with_redirects wrapped_t ~original_url ~follow_redirects ~max_redirects
786 ~timeout ~headers_for_request:headers_for_redirect
787 ~method_:redirect_method ~body:redirect_body absolute_location
788 (redirects_left - 1)
789 end
790 else (status, resp_headers, response_body_str, url_to_fetch)
791
792(* Execute request with redirect handling.
793 headers_for_request: headers for this request (may have auth stripped)
794 method_: HTTP method (may be changed by 303 redirect)
795 body: request body (may be stripped by 303 redirect) *)
796and with_redirects (T t as wrapped_t) ~original_url ~follow_redirects
797 ~max_redirects ~timeout ~headers_for_request ~method_ ~body url_to_fetch
798 redirects_left =
799 let method_str = Method.to_string method_ in
800 let uri_to_fetch = Uri.of_string url_to_fetch in
801 let original_uri = Uri.of_string original_url in
802 let redirect_host, redirect_port, redirect_is_https =
803 extract_url_components uri_to_fetch url_to_fetch
804 in
805 let redirect_endpoint =
806 Conpool.Endpoint.v ~host:redirect_host ~port:redirect_port
807 in
808 let redirect_pool = if redirect_is_https then t.https_pool else t.http_pool in
809 let headers_with_cookies =
810 add_cookies_to_headers ~cookie_mutex:t.cookie_mutex ~cookie_jar:t.cookie_jar
811 ~clock:t.clock ~headers_for_request ~host:redirect_host
812 ~path:(Uri.path uri_to_fetch) ~is_secure:redirect_is_https
813 in
814 log_request_headers ~method_str ~uri:uri_to_fetch
815 ~headers:headers_with_cookies;
816 let use_proxy =
817 match t.proxy with
818 | None -> false
819 | Some proxy -> not (Proxy.should_bypass proxy url_to_fetch)
820 in
821 let endpoint_key = Fmt.str "%s:%d" redirect_host redirect_port in
822 let protocol_hint =
823 Eio.Mutex.use_ro t.protocol_hints_mutex (fun () ->
824 Hashtbl.find_opt t.protocol_hints endpoint_key)
825 in
826 let status, resp_headers, response_body_str =
827 execute_request_with_timeout wrapped_t ~use_proxy ~redirect_is_https
828 ~redirect_pool ~redirect_endpoint ~redirect_host ~redirect_port
829 ~endpoint_key ~uri_to_fetch ~headers:headers_with_cookies ~body ~method_
830 ~protocol_hint ~timeout
831 in
832 log_response_headers ~status ~resp_headers;
833 store_response_cookies wrapped_t ~resp_headers ~url_str:url_to_fetch;
834 handle_redirect_or_return wrapped_t ~original_url ~original_uri
835 ~follow_redirects ~max_redirects ~timeout ~headers_for_request ~method_
836 ~body ~url_to_fetch ~uri_to_fetch ~redirects_left status resp_headers
837 response_body_str
838
839let apply_signature_auth ~clock ~method_ ~uri ~headers auth =
840 match auth with
841 | Some a when Auth.is_signature a ->
842 Auth.apply_signature ~clock ~method_ ~uri ~headers a
843 | _ -> headers
844
845let persist_cookies_if_needed (T t) =
846 match (t.persist_cookies, t.xdg) with
847 | true, Some xdg_ctx ->
848 let data_dir = Xdge.data_dir xdg_ctx in
849 let cookie_file = Eio.Path.(data_dir / "cookies.txt") in
850 Eio.Mutex.use_rw ~protect:true t.cookie_mutex (fun () ->
851 Cookeio_jar.save cookie_file t.cookie_jar;
852 Log.debug (fun m -> m "Saved cookies to %a" Eio.Path.pp cookie_file))
853 | _ -> ()
854
855(* Internal request function using connection pools *)
856let request_internal (T t as wrapped_t) ?headers ?body ?auth ?timeout
857 ?follow_redirects ?max_redirects ?(path_params = []) ~method_ url =
858 let start_time = Unix.gettimeofday () in
859 let url =
860 if path_params = [] then url else substitute_path_params url path_params
861 in
862 let url = resolve_url ?base_url:t.base_url url in
863 Log.info (fun m -> m "Making %s request to %s" (Method.to_string method_) url);
864 let auth, base_headers =
865 build_request_headers ~default_headers:t.default_headers
866 ~cookie_mutex:t.cookie_mutex ~cookie_jar:t.cookie_jar ~clock:t.clock
867 ~xsrf_cookie_name:t.xsrf_cookie_name ~xsrf_header_name:t.xsrf_header_name
868 ~default_auth:t.auth ~allow_insecure_auth:t.allow_insecure_auth
869 ~auto_decompress:t.auto_decompress ~headers ~auth ~body ~url
870 in
871 let request_body = Option.value ~default:Body.empty body in
872 let original_uri = Uri.of_string url in
873 let max_redir = Option.value max_redirects ~default:t.max_redirects in
874 let signed_headers =
875 apply_signature_auth ~clock:t.clock ~method_ ~uri:original_uri
876 ~headers:base_headers auth
877 in
878 let final_status, final_headers, final_body_str, final_url =
879 with_redirects wrapped_t ~original_url:url ~follow_redirects ~max_redirects
880 ~timeout ~headers_for_request:signed_headers ~method_ ~body:request_body
881 url max_redir
882 in
883 let elapsed = Unix.gettimeofday () -. start_time in
884 Log.info (fun m -> m "Request completed in %.3f seconds" elapsed);
885 let body_flow = Eio.Flow.string_source final_body_str in
886 let response =
887 Response.Private.make ~sw:t.sw ~status:final_status ~headers:final_headers
888 ~body:body_flow ~url:final_url ~elapsed
889 in
890 t.requests_made <- t.requests_made + 1;
891 t.total_time <- t.total_time +. (Unix.gettimeofday () -. start_time);
892 Log.info (fun m ->
893 m "Request completed with status %d" (Response.status_code response));
894 persist_cookies_if_needed wrapped_t;
895 response
896
897let build_digest_auth_header ~nonce_counter ~username ~password ~method_ ~uri
898 ~challenge ~auth_header_name base_headers =
899 let auth_value =
900 Auth.apply_digest ~nonce_counter ~username ~password
901 ~method_:(Method.to_string method_) ~uri ~challenge Headers.empty
902 in
903 match Headers.find `Authorization auth_value with
904 | Some v -> Headers.set auth_header_name v base_headers
905 | None -> base_headers
906
907(* Helper to handle Digest authentication challenges (401 and 407).
908 Per RFC 7235: 401 uses WWW-Authenticate/Authorization headers,
909 407 uses Proxy-Authenticate/Proxy-Authorization headers. *)
910let digest_header_names status =
911 let challenge_header : Header_name.t =
912 if status = 401 then `Www_authenticate else `Proxy_authenticate
913 in
914 let auth_header_name : Header_name.t =
915 if status = 401 then `Authorization else `Proxy_authorization
916 in
917 (challenge_header, auth_header_name)
918
919let handle_digest_auth (T t as wrapped_t) ~headers ~body ~auth ~timeout
920 ~follow_redirects ~max_redirects ~method_ ~url response =
921 let status = Response.status_code response in
922 let auth_to_use =
923 match auth with
924 | Some a -> a
925 | None -> Option.value t.auth ~default:Auth.none
926 in
927 let is_auth_challenge =
928 (status = 401 || status = 407) && Auth.is_digest auth_to_use
929 in
930 if is_auth_challenge then begin
931 match Auth.digest_credentials auth_to_use with
932 | Some (username, password) -> (
933 let challenge_header, auth_header_name = digest_header_names status in
934 match Response.header challenge_header response with
935 | Some www_auth -> (
936 match Auth.parse_www_authenticate www_auth with
937 | Some challenge ->
938 Log.info (fun m ->
939 m
940 "Received %s challenge (status %d), retrying with \
941 authentication"
942 (if status = 401 then "Digest" else "Proxy Digest")
943 status);
944 let uri_path =
945 let p = Uri.path (Uri.of_string url) in
946 if p = "" then "/" else p
947 in
948 let base_headers =
949 Option.value headers ~default:Headers.empty
950 in
951 let auth_headers =
952 build_digest_auth_header ~nonce_counter:t.nonce_counter
953 ~username ~password ~method_ ~uri:uri_path ~challenge
954 ~auth_header_name base_headers
955 in
956 request_internal wrapped_t ~headers:auth_headers ?body
957 ~auth:Auth.none ?timeout ?follow_redirects ?max_redirects
958 ~method_ url
959 | None ->
960 Log.warn (fun m ->
961 m "Could not parse Digest challenge from %s"
962 (Header_name.to_string challenge_header));
963 response)
964 | None ->
965 Log.warn (fun m ->
966 m "%d response has no %s header" status
967 (Header_name.to_string challenge_header));
968 response)
969 | None -> response
970 end
971 else response
972
973let compute_retry_delay ~retry_config ~status ~response ~attempt =
974 if retry_config.Retry.respect_retry_after && (status = 429 || status = 503)
975 then
976 match Response.header `Retry_after response with
977 | Some value ->
978 Retry.parse_retry_after value
979 |> Option.value
980 ~default:(Retry.calculate_backoff ~config:retry_config ~attempt)
981 | None -> Retry.calculate_backoff ~config:retry_config ~attempt
982 else Retry.calculate_backoff ~config:retry_config ~attempt
983
984let should_retry_exn = function
985 | Eio.Io (Error.E e, _) -> Error.is_retryable e
986 | Eio.Time.Timeout -> true
987 | _ -> false
988
989let retry_after_delay (T t) ~retry_config ~delay ~headers ~body ~auth ~timeout
990 ~follow_redirects ~max_redirects ~path_params ~method_ ~url
991 ~with_digest_handling ~attempt ~recurse =
992 Eio.Time.sleep t.clock delay;
993 t.retries_count <- t.retries_count + 1;
994 recurse ~headers ~body ~auth ~timeout ~follow_redirects ~max_redirects
995 ~path_params ~method_ ~url ~retry_config ~with_digest_handling (attempt + 1)
996
997let rec attempt_request (T t as wrapped_t) ~headers ~body ~auth ~timeout
998 ~follow_redirects ~max_redirects ~path_params ~method_ ~url ~retry_config
999 ~with_digest_handling attempt =
1000 if attempt > 1 then
1001 Log.info (fun m ->
1002 m "Retry attempt %d/%d for %s %s" attempt
1003 (retry_config.Retry.max_retries + 1)
1004 (Method.to_string method_) url);
1005 let retry ~delay =
1006 retry_after_delay wrapped_t ~retry_config ~delay ~headers ~body ~auth
1007 ~timeout ~follow_redirects ~max_redirects ~path_params ~method_ ~url
1008 ~with_digest_handling ~attempt
1009 ~recurse:(attempt_request wrapped_t)
1010 in
1011 try
1012 let response =
1013 request_internal wrapped_t ?headers ?body ?auth ?timeout ?follow_redirects
1014 ?max_redirects ~path_params ~method_ url
1015 in
1016 let response = with_digest_handling response in
1017 let status = Response.status_code response in
1018 if
1019 attempt <= retry_config.Retry.max_retries
1020 && Retry.should_retry ~config:retry_config ~method_ ~status
1021 then begin
1022 let delay =
1023 compute_retry_delay ~retry_config ~status ~response ~attempt
1024 in
1025 Log.warn (fun m ->
1026 m
1027 "Request returned status %d (attempt %d/%d). Retrying in %.2f \
1028 seconds..."
1029 status attempt
1030 (retry_config.Retry.max_retries + 1)
1031 delay);
1032 retry ~delay
1033 end
1034 else response
1035 with
1036 | exn
1037 when attempt <= retry_config.Retry.max_retries && should_retry_exn exn
1038 ->
1039 let delay = Retry.calculate_backoff ~config:retry_config ~attempt in
1040 Log.warn (fun m ->
1041 m "Request failed (attempt %d/%d): %s. Retrying in %.2f seconds..."
1042 attempt
1043 (retry_config.Retry.max_retries + 1)
1044 (Printexc.to_string exn) delay);
1045 retry ~delay
1046
1047(* Public request function - executes synchronously with retry support *)
1048let request (T t as wrapped_t) ?headers ?body ?auth ?timeout ?follow_redirects
1049 ?max_redirects ?(path_params = []) ~method_ url =
1050 let with_digest_handling response =
1051 handle_digest_auth wrapped_t ~headers ~body ~auth ~timeout ~follow_redirects
1052 ~max_redirects ~method_ ~url response
1053 in
1054 match t.retry with
1055 | None ->
1056 let response =
1057 request_internal wrapped_t ?headers ?body ?auth ?timeout
1058 ?follow_redirects ?max_redirects ~path_params ~method_ url
1059 in
1060 with_digest_handling response
1061 | Some retry_config ->
1062 attempt_request wrapped_t ~headers ~body ~auth ~timeout ~follow_redirects
1063 ~max_redirects ~path_params ~method_ ~url ~retry_config
1064 ~with_digest_handling 1
1065
1066(* Convenience methods *)
1067let get t ?headers ?auth ?timeout ?params ?(path_params = []) url =
1068 let url =
1069 match params with
1070 | Some p ->
1071 let uri = Uri.of_string url in
1072 let uri =
1073 List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v)) uri p
1074 in
1075 Uri.to_string uri
1076 | None -> url
1077 in
1078 request t ?headers ?auth ?timeout ~path_params ~method_:`GET url
1079
1080let post t ?headers ?body ?auth ?timeout ?(path_params = []) url =
1081 request t ?headers ?body ?auth ?timeout ~path_params ~method_:`POST url
1082
1083let put t ?headers ?body ?auth ?timeout ?(path_params = []) url =
1084 request t ?headers ?body ?auth ?timeout ~path_params ~method_:`PUT url
1085
1086let patch t ?headers ?body ?auth ?timeout ?(path_params = []) url =
1087 request t ?headers ?body ?auth ?timeout ~path_params ~method_:`PATCH url
1088
1089let delete t ?headers ?auth ?timeout ?(path_params = []) url =
1090 request t ?headers ?auth ?timeout ~path_params ~method_:`DELETE url
1091
1092let head t ?headers ?auth ?timeout ?(path_params = []) url =
1093 request t ?headers ?auth ?timeout ~path_params ~method_:`HEAD url
1094
1095let options t ?headers ?auth ?timeout ?(path_params = []) url =
1096 request t ?headers ?auth ?timeout ~path_params ~method_:`OPTIONS url
1097
1098(* Cmdliner integration module *)
1099module Cmd = struct
1100 open Cmdliner
1101
1102 (** Source tracking for configuration values. Tracks where each configuration
1103 value came from for debugging and transparency. *)
1104 type source =
1105 | Default (** Value from hardcoded default *)
1106 | Env of string (** Value from environment variable (stores var name) *)
1107 | Cmdline (** Value from command-line argument *)
1108
1109 type 'a with_source = { value : 'a; source : source }
1110 (** Wrapper for values with source tracking *)
1111
1112 type proxy_config = {
1113 proxy_url : string with_source option;
1114 (** Proxy URL (from HTTP_PROXY/HTTPS_PROXY/etc) *)
1115 no_proxy : string with_source option; (** NO_PROXY patterns *)
1116 }
1117 (** Proxy configuration from command line and environment *)
1118
1119 type config = {
1120 xdg : Xdge.t * Xdge.Cmd.t;
1121 persist_cookies : bool with_source;
1122 verify_tls : bool with_source;
1123 timeout : float option with_source;
1124 max_retries : int with_source;
1125 retry_backoff : float with_source;
1126 follow_redirects : bool with_source;
1127 max_redirects : int with_source;
1128 user_agent : string option with_source;
1129 verbose_http : bool with_source;
1130 proxy : proxy_config;
1131 }
1132
1133 (** Helper to check environment variable and track source *)
1134 let check_env_bool ~app_name ~suffix ~default =
1135 let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in
1136 match Sys.getenv_opt env_var with
1137 | Some v
1138 when String.lowercase_ascii v = "1" || String.lowercase_ascii v = "true"
1139 ->
1140 { value = true; source = Env env_var }
1141 | Some v
1142 when String.lowercase_ascii v = "0" || String.lowercase_ascii v = "false"
1143 ->
1144 { value = false; source = Env env_var }
1145 | Some _ | None -> { value = default; source = Default }
1146
1147 let check_env_string ~app_name ~suffix =
1148 let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in
1149 match Sys.getenv_opt env_var with
1150 | Some v when v <> "" -> Some { value = v; source = Env env_var }
1151 | Some _ | None -> None
1152
1153 let check_env_float ~app_name ~suffix ~default =
1154 let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in
1155 match Sys.getenv_opt env_var with
1156 | Some v -> (
1157 try { value = float_of_string v; source = Env env_var }
1158 with Failure _ -> { value = default; source = Default })
1159 | None -> { value = default; source = Default }
1160
1161 let check_env_int ~app_name ~suffix ~default =
1162 let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in
1163 match Sys.getenv_opt env_var with
1164 | Some v -> (
1165 try { value = int_of_string v; source = Env env_var }
1166 with Failure _ -> { value = default; source = Default })
1167 | None -> { value = default; source = Default }
1168
1169 (** Parse proxy configuration from environment. Follows standard
1170 HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY conventions. *)
1171 let proxy_from_env () =
1172 let check_env var =
1173 match Sys.getenv_opt var with
1174 | Some v when v <> "" -> Some { value = v; source = Env var }
1175 | _ -> None
1176 in
1177 let proxy_url =
1178 List.find_map check_env
1179 [
1180 "HTTP_PROXY";
1181 "http_proxy";
1182 "HTTPS_PROXY";
1183 "https_proxy";
1184 "ALL_PROXY";
1185 "all_proxy";
1186 ]
1187 in
1188 let no_proxy = List.find_map check_env [ "NO_PROXY"; "no_proxy" ] in
1189 { proxy_url; no_proxy }
1190
1191 let v config env sw =
1192 let xdg, _xdg_cmd = config.xdg in
1193 let retry =
1194 if config.max_retries.value > 0 then
1195 Some
1196 (Retry.config ~max_retries:config.max_retries.value
1197 ~backoff_factor:config.retry_backoff.value ())
1198 else None
1199 in
1200
1201 let timeout =
1202 match config.timeout.value with
1203 | Some t -> Timeout.v ~total:t ()
1204 | None -> Timeout.default
1205 in
1206
1207 (* Build proxy config if URL is set *)
1208 let proxy =
1209 match config.proxy.proxy_url with
1210 | Some { value = url; _ } ->
1211 let no_proxy =
1212 match config.proxy.no_proxy with
1213 | Some { value = np; _ } ->
1214 np |> String.split_on_char ',' |> List.map String.trim
1215 |> List.filter (fun s -> s <> "")
1216 | None -> []
1217 in
1218 (* Parse proxy URL to extract components *)
1219 let uri = Uri.of_string url in
1220 let host = Uri.host uri |> Option.value ~default:"localhost" in
1221 let port = Uri.port uri |> Option.value ~default:8080 in
1222 let auth =
1223 match Uri.userinfo uri with
1224 | Some info -> (
1225 match String.index_opt info ':' with
1226 | Some idx ->
1227 let username = String.sub info 0 idx in
1228 let password =
1229 String.sub info (idx + 1) (String.length info - idx - 1)
1230 in
1231 Some (Auth.basic ~username ~password)
1232 | None -> Some (Auth.basic ~username:info ~password:""))
1233 | None -> None
1234 in
1235 Some (Proxy.http ~port ?auth ~no_proxy host)
1236 | None -> None
1237 in
1238
1239 let req =
1240 v ~sw ~xdg ~persist_cookies:config.persist_cookies.value
1241 ~verify_tls:config.verify_tls.value ~timeout ?retry
1242 ~follow_redirects:config.follow_redirects.value
1243 ~max_redirects:config.max_redirects.value ?proxy env
1244 in
1245
1246 (* Set user agent if provided *)
1247 let req =
1248 match config.user_agent.value with
1249 | Some ua -> set_default_header req "User-Agent" ua
1250 | None -> req
1251 in
1252
1253 req
1254
1255 (* Individual terms - parameterized by app_name
1256 These terms return with_source wrapped values to track provenance *)
1257
1258 let persist_cookies_term app_name =
1259 let doc = "Persist cookies to disk between sessions" in
1260 let env_name = String.uppercase_ascii app_name ^ "_PERSIST_COOKIES" in
1261 let env_info = Cmdliner.Cmd.Env.info env_name in
1262 let cmdline_arg =
1263 Arg.(value & flag & info [ "persist-cookies" ] ~env:env_info ~doc)
1264 in
1265 Term.(
1266 const (fun cmdline ->
1267 if cmdline then { value = true; source = Cmdline }
1268 else check_env_bool ~app_name ~suffix:"PERSIST_COOKIES" ~default:false)
1269 $ cmdline_arg)
1270
1271 let verify_tls_term app_name =
1272 let doc = "Skip TLS certificate verification (insecure)" in
1273 let env_name = String.uppercase_ascii app_name ^ "_NO_VERIFY_TLS" in
1274 let env_info = Cmdliner.Cmd.Env.info env_name in
1275 let cmdline_arg =
1276 Arg.(value & flag & info [ "no-verify-tls" ] ~env:env_info ~doc)
1277 in
1278 Term.(
1279 const (fun no_verify ->
1280 if no_verify then { value = false; source = Cmdline }
1281 else
1282 let env_val =
1283 check_env_bool ~app_name ~suffix:"NO_VERIFY_TLS" ~default:false
1284 in
1285 { value = not env_val.value; source = env_val.source })
1286 $ cmdline_arg)
1287
1288 let timeout_term app_name =
1289 let doc = "Request timeout in seconds" in
1290 let env_name = String.uppercase_ascii app_name ^ "_TIMEOUT" in
1291 let env_info = Cmdliner.Cmd.Env.info env_name in
1292 let cmdline_arg =
1293 Arg.(
1294 value
1295 & opt (some float) None
1296 & info [ "timeout" ] ~env:env_info ~docv:"SECONDS" ~doc)
1297 in
1298 Term.(
1299 const (fun cmdline ->
1300 match cmdline with
1301 | Some t -> { value = Some t; source = Cmdline }
1302 | None -> (
1303 match check_env_string ~app_name ~suffix:"TIMEOUT" with
1304 | Some { value = v; source } -> (
1305 try { value = Some (float_of_string v); source }
1306 with Failure _ -> { value = None; source = Default })
1307 | None -> { value = None; source = Default }))
1308 $ cmdline_arg)
1309
1310 let retries_term app_name =
1311 let doc = "Maximum number of request retries" in
1312 let env_name = String.uppercase_ascii app_name ^ "_MAX_RETRIES" in
1313 let env_info = Cmdliner.Cmd.Env.info env_name in
1314 let cmdline_arg =
1315 Arg.(
1316 value
1317 & opt (some int) None
1318 & info [ "max-retries" ] ~env:env_info ~docv:"N" ~doc)
1319 in
1320 Term.(
1321 const (fun cmdline ->
1322 match cmdline with
1323 | Some n -> { value = n; source = Cmdline }
1324 | None -> check_env_int ~app_name ~suffix:"MAX_RETRIES" ~default:3)
1325 $ cmdline_arg)
1326
1327 let retry_backoff_term app_name =
1328 let doc = "Retry backoff factor for exponential delay" in
1329 let env_name = String.uppercase_ascii app_name ^ "_RETRY_BACKOFF" in
1330 let env_info = Cmdliner.Cmd.Env.info env_name in
1331 let cmdline_arg =
1332 Arg.(
1333 value
1334 & opt (some float) None
1335 & info [ "retry-backoff" ] ~env:env_info ~docv:"FACTOR" ~doc)
1336 in
1337 Term.(
1338 const (fun cmdline ->
1339 match cmdline with
1340 | Some f -> { value = f; source = Cmdline }
1341 | None ->
1342 check_env_float ~app_name ~suffix:"RETRY_BACKOFF" ~default:0.3)
1343 $ cmdline_arg)
1344
1345 let follow_redirects_term app_name =
1346 let doc = "Don't follow HTTP redirects" in
1347 let env_name = String.uppercase_ascii app_name ^ "_NO_FOLLOW_REDIRECTS" in
1348 let env_info = Cmdliner.Cmd.Env.info env_name in
1349 let cmdline_arg =
1350 Arg.(value & flag & info [ "no-follow-redirects" ] ~env:env_info ~doc)
1351 in
1352 Term.(
1353 const (fun no_follow ->
1354 if no_follow then { value = false; source = Cmdline }
1355 else
1356 let env_val =
1357 check_env_bool ~app_name ~suffix:"NO_FOLLOW_REDIRECTS"
1358 ~default:false
1359 in
1360 { value = not env_val.value; source = env_val.source })
1361 $ cmdline_arg)
1362
1363 let max_redirects_term app_name =
1364 let doc = "Maximum number of redirects to follow" in
1365 let env_name = String.uppercase_ascii app_name ^ "_MAX_REDIRECTS" in
1366 let env_info = Cmdliner.Cmd.Env.info env_name in
1367 let cmdline_arg =
1368 Arg.(
1369 value
1370 & opt (some int) None
1371 & info [ "max-redirects" ] ~env:env_info ~docv:"N" ~doc)
1372 in
1373 Term.(
1374 const (fun cmdline ->
1375 match cmdline with
1376 | Some n -> { value = n; source = Cmdline }
1377 | None -> check_env_int ~app_name ~suffix:"MAX_REDIRECTS" ~default:10)
1378 $ cmdline_arg)
1379
1380 let user_agent_term app_name =
1381 let doc = "User-Agent header to send with requests" in
1382 let env_name = String.uppercase_ascii app_name ^ "_USER_AGENT" in
1383 let env_info = Cmdliner.Cmd.Env.info env_name in
1384 let cmdline_arg =
1385 Arg.(
1386 value
1387 & opt (some string) None
1388 & info [ "user-agent" ] ~env:env_info ~docv:"STRING" ~doc)
1389 in
1390 Term.(
1391 const (fun cmdline ->
1392 match cmdline with
1393 | Some ua -> { value = Some ua; source = Cmdline }
1394 | None -> (
1395 match check_env_string ~app_name ~suffix:"USER_AGENT" with
1396 | Some { value; source } -> { value = Some value; source }
1397 | None -> { value = None; source = Default }))
1398 $ cmdline_arg)
1399
1400 let verbose_http_term app_name =
1401 let doc = "Enable verbose HTTP-level logging (hexdumps, TLS details)" in
1402 let env_name = String.uppercase_ascii app_name ^ "_VERBOSE_HTTP" in
1403 let env_info = Cmdliner.Cmd.Env.info env_name in
1404 let cmdline_arg =
1405 Arg.(value & flag & info [ "verbose-http" ] ~env:env_info ~doc)
1406 in
1407 Term.(
1408 const (fun cmdline ->
1409 if cmdline then { value = true; source = Cmdline }
1410 else check_env_bool ~app_name ~suffix:"VERBOSE_HTTP" ~default:false)
1411 $ cmdline_arg)
1412
1413 let proxy_term _app_name =
1414 let doc = "HTTP/HTTPS proxy URL (e.g., http://proxy:8080)" in
1415 let cmdline_arg =
1416 Arg.(value & opt (some string) None & info [ "proxy" ] ~docv:"URL" ~doc)
1417 in
1418 let no_proxy_doc = "Comma-separated list of hosts to bypass proxy" in
1419 let no_proxy_arg =
1420 Arg.(
1421 value
1422 & opt (some string) None
1423 & info [ "no-proxy" ] ~docv:"HOSTS" ~doc:no_proxy_doc)
1424 in
1425 Term.(
1426 const (fun cmdline_proxy cmdline_no_proxy ->
1427 let proxy_url =
1428 match cmdline_proxy with
1429 | Some url -> Some { value = url; source = Cmdline }
1430 | None -> (proxy_from_env ()).proxy_url
1431 in
1432 let no_proxy =
1433 match cmdline_no_proxy with
1434 | Some np -> Some { value = np; source = Cmdline }
1435 | None -> (proxy_from_env ()).no_proxy
1436 in
1437 { proxy_url; no_proxy })
1438 $ cmdline_arg $ no_proxy_arg)
1439
1440 (* Combined terms *)
1441
1442 let config_term app_name fs =
1443 let xdg_term =
1444 Xdge.Cmd.term app_name fs ~dirs:[ `Config; `Data; `Cache ] ()
1445 in
1446 Term.(
1447 const
1448 (fun
1449 xdg
1450 persist
1451 verify
1452 timeout
1453 retries
1454 backoff
1455 follow
1456 max_redir
1457 ua
1458 verbose
1459 proxy
1460 ->
1461 {
1462 xdg;
1463 persist_cookies = persist;
1464 verify_tls = verify;
1465 timeout;
1466 max_retries = retries;
1467 retry_backoff = backoff;
1468 follow_redirects = follow;
1469 max_redirects = max_redir;
1470 user_agent = ua;
1471 verbose_http = verbose;
1472 proxy;
1473 })
1474 $ xdg_term
1475 $ persist_cookies_term app_name
1476 $ verify_tls_term app_name $ timeout_term app_name $ retries_term app_name
1477 $ retry_backoff_term app_name
1478 $ follow_redirects_term app_name
1479 $ max_redirects_term app_name
1480 $ user_agent_term app_name $ verbose_http_term app_name
1481 $ proxy_term app_name)
1482
1483 let requests_term app_name eio_env sw =
1484 let config_t = config_term app_name eio_env#fs in
1485 Term.(const (fun config -> v config eio_env sw) $ config_t)
1486
1487 let minimal_term app_name fs =
1488 let xdg_term = Xdge.Cmd.term app_name fs ~dirs:[ `Data; `Cache ] () in
1489 Term.(
1490 const (fun (xdg, _xdg_cmd) persist -> (xdg, persist.value))
1491 $ xdg_term
1492 $ persist_cookies_term app_name)
1493
1494 let env_docs app_name =
1495 let app_upper = String.uppercase_ascii app_name in
1496 Fmt.str
1497 "## ENVIRONMENT\n\n\
1498 The following environment variables affect %s:\n\n\
1499 ### XDG Directories\n\n\
1500 **%s_CONFIG_DIR**\n\
1501 : Override configuration directory location\n\n\
1502 **%s_DATA_DIR**\n\
1503 : Override data directory location (for cookies)\n\n\
1504 **%s_CACHE_DIR**\n\
1505 : Override cache directory location\n\n\
1506 **XDG_CONFIG_HOME**\n\
1507 : Base directory for user configuration files (default: ~/.config)\n\n\
1508 **XDG_DATA_HOME**\n\
1509 : Base directory for user data files (default: ~/.local/share)\n\n\
1510 **XDG_CACHE_HOME**\n\
1511 : Base directory for user cache files (default: ~/.cache)\n\n\
1512 ### HTTP Settings\n\n\
1513 **%s_PERSIST_COOKIES**\n\
1514 : Set to '1' to persist cookies by default\n\n\
1515 **%s_NO_VERIFY_TLS**\n\
1516 : Set to '1' to disable TLS verification (insecure)\n\n\
1517 **%s_TIMEOUT**\n\
1518 : Default request timeout in seconds\n\n\
1519 **%s_MAX_RETRIES**\n\
1520 : Maximum number of retries (default: 3)\n\n\
1521 **%s_RETRY_BACKOFF**\n\
1522 : Retry backoff factor (default: 0.3)\n\n\
1523 **%s_NO_FOLLOW_REDIRECTS**\n\
1524 : Set to '1' to disable redirect following\n\n\
1525 **%s_MAX_REDIRECTS**\n\
1526 : Maximum redirects to follow (default: 10)\n\n\
1527 **%s_USER_AGENT**\n\
1528 : User-Agent header to send with requests\n\n\
1529 **%s_VERBOSE_HTTP**\n\
1530 : Set to '1' to enable verbose HTTP-level logging\n\n\
1531 ### Proxy Configuration\n\n\
1532 **HTTP_PROXY** / **http_proxy**\n\
1533 : HTTP proxy URL (e.g., http://proxy:8080 or \
1534 http://user:pass@proxy:8080)\n\n\
1535 **HTTPS_PROXY** / **https_proxy**\n\
1536 : HTTPS proxy URL (used for HTTPS requests)\n\n\
1537 **ALL_PROXY** / **all_proxy**\n\
1538 : Fallback proxy URL for all protocols\n\n\
1539 **NO_PROXY** / **no_proxy**\n\
1540 : Comma-separated list of hosts to bypass proxy (e.g., \
1541 localhost,*.example.com)"
1542 app_name app_upper app_upper app_upper app_upper app_upper app_upper
1543 app_upper app_upper app_upper app_upper app_upper app_upper
1544
1545 (** Pretty-print source type *)
1546 let pp_source ppf = function
1547 | Default -> Fmt.pf ppf "default"
1548 | Env var -> Fmt.pf ppf "env(%s)" var
1549 | Cmdline -> Fmt.pf ppf "cmdline"
1550
1551 (** Pretty-print a value with its source *)
1552 let pp_with_source pp_val ppf ws =
1553 Fmt.pf ppf "%a [%a]" pp_val ws.value pp_source ws.source
1554
1555 let pp_config ?(show_sources = true) ppf config =
1556 let _xdg, xdg_cmd = config.xdg in
1557 let pp_bool = Fmt.bool in
1558 let pp_float = Fmt.float in
1559 let pp_int = Fmt.int in
1560 let pp_string_opt = Fmt.option Fmt.string in
1561 let pp_float_opt = Fmt.option Fmt.float in
1562
1563 let pp_val pp =
1564 if show_sources then pp_with_source pp else fun ppf ws -> pp ppf ws.value
1565 in
1566
1567 Fmt.pf ppf
1568 "@[<v>Configuration:@,\
1569 @[<v 2>XDG:@,\
1570 %a@]@,\
1571 persist_cookies: %a@,\
1572 verify_tls: %a@,\
1573 timeout: %a@,\
1574 max_retries: %a@,\
1575 retry_backoff: %a@,\
1576 follow_redirects: %a@,\
1577 max_redirects: %a@,\
1578 user_agent: %a@,\
1579 verbose_http: %a@,\
1580 @[<v 2>Proxy:@,\
1581 url: %a@,\
1582 no_proxy: %a@]@]"
1583 Xdge.Cmd.pp xdg_cmd (pp_val pp_bool) config.persist_cookies
1584 (pp_val pp_bool) config.verify_tls (pp_val pp_float_opt) config.timeout
1585 (pp_val pp_int) config.max_retries (pp_val pp_float) config.retry_backoff
1586 (pp_val pp_bool) config.follow_redirects (pp_val pp_int)
1587 config.max_redirects (pp_val pp_string_opt) config.user_agent
1588 (pp_val pp_bool) config.verbose_http
1589 (Fmt.option (pp_with_source Fmt.string))
1590 config.proxy.proxy_url
1591 (Fmt.option (pp_with_source Fmt.string))
1592 config.proxy.no_proxy
1593
1594 (* Logging configuration *)
1595 let setup_log_sources ?(verbose_http = false) level =
1596 (* Helper to set TLS tracing level by finding the source by name *)
1597 let set_tls_tracing_level lvl =
1598 match
1599 List.find_opt
1600 (fun s -> Logs.Src.name s = "tls.tracing")
1601 (Logs.Src.list ())
1602 with
1603 | Some tls_src -> Logs.Src.set_level tls_src (Some lvl)
1604 | None -> () (* TLS not loaded yet, ignore *)
1605 in
1606 match level with
1607 | Some Logs.Debug ->
1608 (* Enable debug logging for application-level requests modules *)
1609 Logs.Src.set_level src (Some Logs.Debug);
1610 Logs.Src.set_level Auth.src (Some Logs.Debug);
1611 Logs.Src.set_level Body.src (Some Logs.Debug);
1612 Logs.Src.set_level Response.src (Some Logs.Debug);
1613 Logs.Src.set_level Retry.src (Some Logs.Debug);
1614 Logs.Src.set_level Headers.src (Some Logs.Debug);
1615 Logs.Src.set_level Error.src (Some Logs.Debug);
1616 Logs.Src.set_level Method.src (Some Logs.Debug);
1617 Logs.Src.set_level Mime.src (Some Logs.Debug);
1618 Logs.Src.set_level Status.src (Some Logs.Debug);
1619 Logs.Src.set_level Timeout.src (Some Logs.Debug);
1620 (* Only enable HTTP-level debug if verbose_http is set *)
1621 if verbose_http then begin
1622 Logs.Src.set_level One.src (Some Logs.Debug);
1623 Logs.Src.set_level Http_client.src (Some Logs.Debug);
1624 Logs.Src.set_level Conpool.src (Some Logs.Debug);
1625 set_tls_tracing_level Logs.Debug
1626 end
1627 else begin
1628 Logs.Src.set_level One.src (Some Logs.Info);
1629 Logs.Src.set_level Http_client.src (Some Logs.Info);
1630 Logs.Src.set_level Conpool.src (Some Logs.Info);
1631 set_tls_tracing_level Logs.Warning
1632 end
1633 | Some Logs.Info ->
1634 (* Set info level for main modules *)
1635 Logs.Src.set_level src (Some Logs.Info);
1636 Logs.Src.set_level Response.src (Some Logs.Info);
1637 Logs.Src.set_level One.src (Some Logs.Info);
1638 set_tls_tracing_level Logs.Warning
1639 | _ ->
1640 (* Suppress TLS debug output by default *)
1641 set_tls_tracing_level Logs.Warning
1642end
1643
1644let pp fmt (T { base_url; auth; _ }) =
1645 Fmt.pf fmt "Requests.t(base_url=%s, auth=%a)"
1646 (Option.value ~default:"<none>" base_url)
1647 (Fmt.option Auth.pp) auth
1648
1649(** {1 Supporting Types} *)
1650
1651module Huri = Huri