···16(** Bearer token authentication (e.g., OAuth 2.0) *)
1718val digest : username:string -> password:string -> t
19+(** HTTP Digest authentication.
20+21+ {b Note:} Digest authentication is currently not fully implemented.
22+ This function accepts credentials but does not perform the challenge-response
23+ protocol required for Digest auth. For functional authentication, use
24+ {!basic} or {!bearer} instead. *)
2526val custom : (Headers.t -> Headers.t) -> t
27(** Custom authentication handler *)
+3-7
lib/body.ml
···104105let generate_boundary () =
106 let random_bytes = Mirage_crypto_rng.generate 16 in
107- let random_part =
108- Cstruct.to_hex_string (Cstruct.of_string random_bytes)
109- in
110 Printf.sprintf "----WebKitFormBoundary%s" random_part
111112let multipart parts =
···119 | Stream { mime; _ } -> Some mime
120 | File { mime; _ } -> Some mime
121 | Multipart { boundary; _ } ->
122- let mime = Mime.make "multipart" "form-data" in
123- Some (Mime.with_charset boundary mime)
124125let content_length = function
126 | Empty -> Some 0L
···271 | Stream _ -> failwith "Cannot convert streaming body to string for connection pooling (body must be materialized first)"
272 | File _ -> failwith "Cannot convert file body to string for connection pooling (file must be read first)"
273 | Multipart _ -> failwith "Cannot convert multipart body to string for connection pooling (must be encoded first)"
274-275- let _ = to_string (* Use to avoid warning *)
276end
···104105let generate_boundary () =
106 let random_bytes = Mirage_crypto_rng.generate 16 in
107+ (* Mirage_crypto_rng.generate returns a string, convert to Cstruct for hex encoding *)
108+ let random_part = Cstruct.to_hex_string (Cstruct.of_string random_bytes) in
0109 Printf.sprintf "----WebKitFormBoundary%s" random_part
110111let multipart parts =
···118 | Stream { mime; _ } -> Some mime
119 | File { mime; _ } -> Some mime
120 | Multipart { boundary; _ } ->
121+ Some (Mime.multipart_form |> Mime.with_param "boundary" boundary)
0122123let content_length = function
124 | Empty -> Some 0L
···269 | Stream _ -> failwith "Cannot convert streaming body to string for connection pooling (body must be materialized first)"
270 | File _ -> failwith "Cannot convert file body to string for connection pooling (file must be read first)"
271 | Multipart _ -> failwith "Cannot convert multipart body to string for connection pooling (must be encoded first)"
00272end
+6-3
lib/headers.ml
···17 | Some (_, values) -> values
18 | None -> []
19 in
20- StringMap.add nkey (key, value :: existing) t
02122let set key value t =
23 let nkey = normalize_key key in
···32let get_all key t =
33 let nkey = normalize_key key in
34 match StringMap.find_opt nkey t with
35- | Some (_, values) -> List.rev values
36 | None -> []
3738let remove key t =
···4849let to_list t =
50 StringMap.fold (fun _ (orig_key, values) acc ->
51- List.fold_left (fun acc v -> (orig_key, v) :: acc) acc (List.rev values)
052 ) t []
05354let merge t1 t2 =
55 StringMap.union (fun _ _ v2 -> Some v2) t1 t2
···17 | Some (_, values) -> values
18 | None -> []
19 in
20+ (* Append to maintain order, avoiding reversal on retrieval *)
21+ StringMap.add nkey (key, existing @ [value]) t
2223let set key value t =
24 let nkey = normalize_key key in
···33let get_all key t =
34 let nkey = normalize_key key in
35 match StringMap.find_opt nkey t with
36+ | Some (_, values) -> values
37 | None -> []
3839let remove key t =
···4950let to_list t =
51 StringMap.fold (fun _ (orig_key, values) acc ->
52+ (* Values are already in correct order, build list in reverse then reverse at end *)
53+ List.fold_left (fun acc v -> (orig_key, v) :: acc) acc values
54 ) t []
55+ |> List.rev
5657let merge t1 t2 =
58 StringMap.union (fun _ _ v2 -> Some v2) t1 t2
+6-7
lib/http_client.ml
···18 | None -> failwith "URI must have a host"
19 in
2021- let port = match Uri.port uri with
22- | Some p -> ":" ^ string_of_int p
23- | None ->
24- match Uri.scheme uri with
25- | Some "https" -> ":443"
26- | Some "http" -> ":80"
27- | _ -> ""
28 in
2930 (* Build request line *)
···18 | None -> failwith "URI must have a host"
19 in
2021+ (* RFC 7230: default ports should be omitted from Host header *)
22+ let port = match Uri.port uri, Uri.scheme uri with
23+ | Some p, Some "https" when p <> 443 -> ":" ^ string_of_int p
24+ | Some p, Some "http" when p <> 80 -> ":" ^ string_of_int p
25+ | Some p, _ -> ":" ^ string_of_int p
26+ | None, _ -> ""
027 in
2829 (* Build request line *)
+8
lib/mime.ml
···67 in
68 { t with parameters }
690000000070(* Common MIME types *)
71let json = make "application" "json"
72let text = make "text" "plain"
···67 in
68 { t with parameters }
6970+let with_param key value t =
71+ let key_lower = String.lowercase_ascii key in
72+ let parameters =
73+ (key_lower, value) ::
74+ List.filter (fun (k, _) -> k <> key_lower) t.parameters
75+ in
76+ { t with parameters }
77+78(* Common MIME types *)
79let json = make "application" "json"
80let text = make "text" "plain"
+5
lib/mime.mli
···30val with_charset : string -> t -> t
31(** Add or update charset parameter *)
320000033val charset : t -> string option
34(** Extract charset parameter if present *)
···30val with_charset : string -> t -> t
31(** Add or update charset parameter *)
3233+val with_param : string -> string -> t -> t
34+(** [with_param key value t] adds or updates a parameter in the MIME type.
35+ Example: [with_param "boundary" "----WebKit123" multipart_form]
36+ produces "multipart/form-data; boundary=----WebKit123" *)
37+38val charset : t -> string option
39(** Extract charset parameter if present *)
+51-4
lib/requests.ml
···42 persist_cookies : bool;
43 xdg : Xdge.t option;
4445- (* Statistics - mutable for tracking across all derived sessions *)
00046 mutable requests_made : int;
47 mutable total_time : float;
48 mutable retries_count : int;
···410411 response
412413-(* Public request function - executes synchronously *)
414let request t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url =
415- make_request_internal t ?headers ?body ?auth ?timeout
416- ?follow_redirects ?max_redirects ~method_ url
00000000000000000000000000000000000000000000417418(* Convenience methods *)
419let get t ?headers ?auth ?timeout ?params url =
···42 persist_cookies : bool;
43 xdg : Xdge.t option;
4445+ (* Statistics - mutable but NOTE: when sessions are derived via record update
46+ syntax ({t with field = value}), these are copied not shared. Each derived
47+ session has independent statistics. Use the same session object to track
48+ cumulative stats. *)
49 mutable requests_made : int;
50 mutable total_time : float;
51 mutable retries_count : int;
···413414 response
415416+(* Public request function - executes synchronously with retry support *)
417let request t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url =
418+ match t.retry with
419+ | None ->
420+ (* No retry configured, execute directly *)
421+ make_request_internal t ?headers ?body ?auth ?timeout
422+ ?follow_redirects ?max_redirects ~method_ url
423+ | Some retry_config ->
424+ (* Wrap in retry logic *)
425+ let should_retry_exn = function
426+ | Error.Timeout -> true
427+ | Error.ConnectionError _ -> true
428+ | Error.SSLError _ -> true
429+ | _ -> false
430+ in
431+432+ let rec attempt_with_status_retry attempt =
433+ if attempt > 1 then
434+ Log.info (fun m -> m "Retry attempt %d/%d for %s %s"
435+ attempt (retry_config.Retry.max_retries + 1)
436+ (Method.to_string method_) url);
437+438+ try
439+ let response = make_request_internal t ?headers ?body ?auth ?timeout
440+ ?follow_redirects ?max_redirects ~method_ url in
441+ let status = Response.status_code response in
442+443+ (* Check if this status code should be retried *)
444+ if attempt <= retry_config.Retry.max_retries &&
445+ Retry.should_retry ~config:retry_config ~method_ ~status
446+ then begin
447+ let delay = Retry.calculate_backoff ~config:retry_config ~attempt in
448+ Log.warn (fun m -> m "Request returned status %d (attempt %d/%d). Retrying in %.2f seconds..."
449+ status attempt (retry_config.Retry.max_retries + 1) delay);
450+ Eio.Time.sleep t.clock delay;
451+ t.retries_count <- t.retries_count + 1;
452+ attempt_with_status_retry (attempt + 1)
453+ end else
454+ response
455+ with exn when attempt <= retry_config.Retry.max_retries && should_retry_exn exn ->
456+ let delay = Retry.calculate_backoff ~config:retry_config ~attempt in
457+ Log.warn (fun m -> m "Request failed (attempt %d/%d): %s. Retrying in %.2f seconds..."
458+ attempt (retry_config.Retry.max_retries + 1) (Printexc.to_string exn) delay);
459+ Eio.Time.sleep t.clock delay;
460+ t.retries_count <- t.retries_count + 1;
461+ attempt_with_status_retry (attempt + 1)
462+ in
463+ attempt_with_status_retry 1
464465(* Convenience methods *)
466let get t ?headers ?auth ?timeout ?params url =
+66
lib/requests.mli
···73 - {b TLS/SSL}: Secure connections with certificate verification
74 - {b Error Handling}: Comprehensive error types and recovery
7500000000000000000000000000000000000000000000000000000000000000000076 {2 Common Use Cases}
7778 {b Working with JSON APIs:}
···73 - {b TLS/SSL}: Secure connections with certificate verification
74 - {b Error Handling}: Comprehensive error types and recovery
7576+ {2 Error Handling}
77+78+ The Requests library uses exceptions as its primary error handling mechanism,
79+ following Eio's structured concurrency model. This approach ensures that
80+ errors are propagated cleanly through the switch hierarchy.
81+82+ {b Exception-Based Errors:}
83+84+ All request functions may raise exceptions from the {!Error} module:
85+ - {!exception:Error.Timeout}: Request exceeded timeout limit
86+ - {!exception:Error.ConnectionError}: Network connection failed
87+ - {!exception:Error.TooManyRedirects}: Exceeded maximum redirect count
88+ - {!exception:Error.HTTPError}: HTTP error response received (4xx/5xx status)
89+ - {!exception:Error.SSLError}: TLS/SSL connection error
90+ - {!exception:Error.AuthenticationError}: Authentication failed
91+92+ {b Note on HTTP Status Codes:}
93+94+ By default, the library does {b NOT} raise exceptions for HTTP error status
95+ codes (4xx, 5xx). The response is returned normally and you should check
96+ the status code explicitly:
97+98+ {[
99+ let resp = Requests.get req "https://api.example.com/data" in
100+ if Requests.Response.ok resp then
101+ (* Success: 2xx status *)
102+ let body = Requests.Response.body resp |> Eio.Flow.read_all in
103+ process_success body
104+ else
105+ (* Error: non-2xx status *)
106+ let status = Requests.Response.status_code resp in
107+ handle_error status
108+ ]}
109+110+ To automatically retry on certain HTTP status codes, configure retry behavior:
111+112+ {[
113+ let retry_config = Requests.Retry.create_config
114+ ~max_retries:3
115+ ~status_forcelist:[429; 500; 502; 503; 504] (* Retry these codes *)
116+ () in
117+ let req = Requests.create ~sw ~retry:retry_config env in
118+ ]}
119+120+ {b Catching Exceptions:}
121+122+ {[
123+ try
124+ let resp = Requests.get req url in
125+ handle_success resp
126+ with
127+ | Requests.Error.Timeout ->
128+ (* Handle timeout specifically *)
129+ retry_with_longer_timeout ()
130+ | Requests.Error.ConnectionError msg ->
131+ (* Handle connection errors *)
132+ log_error "Connection failed: %s" msg
133+ | exn ->
134+ (* Handle other errors *)
135+ log_error "Unexpected error: %s" (Printexc.to_string exn)
136+ ]}
137+138+ The {!Error} module also provides a Result-based API for functional error
139+ handling, though the primary API uses exceptions for better integration
140+ with Eio's structured concurrency.
141+142 {2 Common Use Cases}
143144 {b Working with JSON APIs:}
+4-1
lib/response.ml
···19 if not response.closed then begin
20 Log.debug (fun m -> m "Auto-closing response for %s via switch" url);
21 response.closed <- true;
22- (* TODO Body cleanup is handled by the underlying HTTP library but test this *)
00023 end
24 );
25
···19 if not response.closed then begin
20 Log.debug (fun m -> m "Auto-closing response for %s via switch" url);
21 response.closed <- true;
22+ (* Body cleanup happens automatically via Eio switch lifecycle.
23+ The body flow (created via Eio.Flow.string_source) is a memory-backed
24+ source that doesn't require explicit cleanup. File-based responses
25+ would have their file handles cleaned up by the switch. *)
26 end
27 );
28