My agentic slop goes here. Not intended for anyone else!

more

-524
stack/requests/DESIGN.md
···
-
# OCaml Requests Library - Design Document for Future Features
-
-
This document outlines the design for features that are planned but not yet implemented in the OCaml Requests library.
-
-
## 1. Hooks System (Middleware)
-
-
### Overview
-
A flexible hooks system that allows users to register callbacks at various stages of the request/response lifecycle, similar to Python requests' hooks system.
-
-
### Design
-
-
```ocaml
-
module Hooks : sig
-
type stage =
-
| Pre_request (* Before request is sent *)
-
| Post_request (* After request is sent, before reading response *)
-
| Pre_redirect (* Before following a redirect *)
-
| Response (* After response is received *)
-
| Error (* On error *)
-
-
type hook = {
-
stage : stage;
-
handler : context -> context;
-
}
-
-
and context = {
-
request : prepared_request;
-
response : Response.t option;
-
error : error option;
-
metadata : (string * string) list;
-
}
-
-
val register : hook -> unit
-
val unregister : stage -> unit
-
val clear : unit -> unit
-
-
(* Session-level hooks *)
-
module Session : sig
-
val add_hook : 'a Session.t -> hook -> unit
-
val remove_hook : 'a Session.t -> stage -> unit
-
end
-
end
-
```
-
-
### Use Cases
-
- Logging all requests/responses
-
- Adding authentication dynamically
-
- Request/response transformation
-
- Metrics collection
-
- Error reporting
-
-
### Implementation Notes
-
- Hooks should be composable (multiple hooks per stage)
-
- Hooks can modify the request/response
-
- Hooks can short-circuit the request (for caching, mocking)
-
- Consider priority ordering for hooks
-
-
## 2. SOCKS Proxy Support
-
-
### Overview
-
Support for SOCKS4/SOCKS5 proxies in addition to HTTP proxies.
-
-
### Design
-
-
```ocaml
-
module SocksProxy : sig
-
type version = SOCKS4 | SOCKS4A | SOCKS5
-
-
type config = {
-
version : version;
-
host : string;
-
port : int;
-
username : string option;
-
password : string option;
-
dns_through_proxy : bool; (* SOCKS5 feature *)
-
}
-
-
val create : config -> Proxy.t
-
-
(* Integration with PoolManager *)
-
val with_socks_proxy :
-
'a PoolManager.t ->
-
socks_config:config ->
-
'a PoolManager.t
-
end
-
```
-
-
### Implementation Strategy
-
- Use a pure OCaml SOCKS implementation or bind to existing C library
-
- Integrate with Eio's networking layer
-
- Support both SOCKS4 and SOCKS5 protocols
-
- Handle SOCKS authentication methods
-
-
### Dependencies
-
- Might need a SOCKS protocol implementation library
-
- Consider using `ocaml-socks` if available
-
-
## 3. Character Encoding Detection
-
-
### Overview
-
Automatic detection and handling of character encodings in responses, similar to Python's chardet.
-
-
### Design
-
-
```ocaml
-
module Encoding : sig
-
type t =
-
| UTF8
-
| UTF16
-
| Latin1
-
| ASCII
-
| Custom of string
-
-
val detect : string -> (t * float) (* encoding * confidence *)
-
val decode : t -> string -> string
-
-
(* Response integration *)
-
val auto_decode : Response.t -> string
-
val with_encoding : Response.t -> t -> string
-
-
(* Streaming support *)
-
val decoder : t -> Flow.source_ty Flow.source -> Flow.source_ty Flow.source
-
end
-
```
-
-
### Implementation Strategy
-
- Port or wrap an existing charset detection library
-
- Integrate with Response module for automatic decoding
-
- Support streaming decoding for large responses
-
- Fallback chain: Content-Type header → BOM detection → Statistical analysis
-
-
### Performance Considerations
-
- Cache detection results
-
- Lazy decoding for large responses
-
- Fast-path for common encodings (UTF-8, ASCII)
-
-
## 4. Compression Support (gzip, deflate)
-
-
### Overview
-
Automatic handling of compressed responses and request body compression.
-
-
### Design
-
-
```ocaml
-
module Compression : sig
-
type algorithm = Gzip | Deflate | Brotli | Zstd | Identity
-
-
val supported : algorithm list
-
-
(* Request compression *)
-
val compress_body :
-
algorithm ->
-
string ->
-
string
-
-
val compress_stream :
-
algorithm ->
-
Flow.source_ty Flow.source ->
-
Flow.source_ty Flow.source
-
-
(* Response decompression *)
-
val decompress :
-
Response.t ->
-
Response.t (* Automatically decompresses based on Content-Encoding *)
-
-
val decompress_stream :
-
algorithm ->
-
Flow.source_ty Flow.source ->
-
Flow.source_ty Flow.source
-
-
(* Config integration *)
-
module Config : sig
-
val with_compression :
-
Config.t ->
-
accept:algorithm list ->
-
compress_request:bool ->
-
Config.t
-
end
-
end
-
```
-
-
### Implementation Strategy
-
- Use `camlzip` for gzip/deflate
-
- Optional support for brotli when OCaml bindings available
-
- Transparent decompression by default
-
- Streaming compression/decompression for large payloads
-
-
### Integration Points
-
- Automatic Accept-Encoding header
-
- Automatic Content-Encoding for requests
-
- Response.body should auto-decompress
-
- Preserve original compressed body option
-
-
## 5. Advanced Certificate Handling
-
-
### Overview
-
Enhanced certificate validation, pinning, and custom verification.
-
-
### Design
-
-
```ocaml
-
module Certificate : sig
-
type pin =
-
| SHA256 of string (* Base64 encoded SHA256 hash *)
-
| SHA1 of string (* Legacy *)
-
| Full of X509.t (* Full certificate *)
-
-
type verification =
-
| Default
-
| Pinned of pin list
-
| Custom of (X509.t list -> bool)
-
| ChainValidator of (X509.t list -> (bool * string))
-
-
val pin_from_file : string -> pin
-
val pin_from_cert : X509.t -> pin
-
-
(* Config integration *)
-
val with_verification :
-
Tls.config ->
-
verification ->
-
Tls.config
-
-
(* Session trust store *)
-
module TrustStore : sig
-
type t
-
val create : unit -> t
-
val add_ca : t -> X509.t -> unit
-
val add_pins : t -> Uri.t -> pin list -> unit
-
val verify : t -> Uri.t -> X509.t list -> bool
-
end
-
end
-
```
-
-
### Security Features
-
- Certificate pinning (HPKP-style)
-
- Custom certificate validation logic
-
- Per-domain certificate rules
-
- Certificate transparency log checking
-
- OCSP stapling support
-
-
## 6. Request Preparation & Signing
-
-
### Overview
-
Separate request preparation from execution, allowing for request signing and modification.
-
-
### Design
-
-
```ocaml
-
module PreparedRequest : sig
-
type t = {
-
method_ : meth;
-
url : Uri.t;
-
headers : Cohttp.Header.t;
-
body : [`String of string | `Stream of Flow.source_ty Flow.source] option;
-
auth : Auth.t option;
-
metadata : (string * string) list;
-
}
-
-
val create :
-
method_:meth ->
-
url:Uri.t ->
-
?headers:Cohttp.Header.t ->
-
?body:string ->
-
?auth:Auth.t ->
-
unit -> t
-
-
val sign :
-
t ->
-
algorithm:[`HMAC_SHA256 | `RSA_SHA256 | `Ed25519] ->
-
key:string ->
-
t
-
-
val add_metadata : t -> string -> string -> t
-
val with_body : t -> string -> t
-
val with_header : t -> string -> string -> t
-
-
(* Execute prepared request *)
-
val execute :
-
sw:Switch.t ->
-
'a t ->
-
PreparedRequest.t ->
-
Response.t
-
end
-
```
-
-
### Use Cases
-
- AWS Signature Version 4 signing
-
- API request signing (webhooks)
-
- Request templating
-
- Batch request preparation
-
- Testing and mocking
-
-
## 7. WebSocket Support
-
-
### Overview
-
WebSocket client support integrated with the HTTP client infrastructure.
-
-
### Design
-
-
```ocaml
-
module WebSocket : sig
-
type t
-
-
type frame =
-
| Text of string
-
| Binary of bytes
-
| Ping of bytes
-
| Pong of bytes
-
| Close of int * string
-
-
val connect :
-
sw:Switch.t ->
-
'a t ->
-
?config:Config.t ->
-
Uri.t ->
-
t
-
-
val send : t -> frame -> unit
-
val receive : t -> frame
-
val close : t -> unit
-
-
(* Higher-level API *)
-
val send_text : t -> string -> unit
-
val send_binary : t -> bytes -> unit
-
val iter_frames : t -> (frame -> unit) -> unit
-
end
-
```
-
-
### Implementation Notes
-
- Reuse connection pooling infrastructure
-
- Support for WebSocket over HTTP/2
-
- Automatic ping/pong handling
-
- Reconnection support
-
-
## 8. HTTP/2 and HTTP/3 Support
-
-
### Overview
-
Support for modern HTTP protocols with multiplexing and improved performance.
-
-
### Design
-
-
```ocaml
-
module Http2 : sig
-
type multiplexed_session
-
-
val create_session :
-
sw:Switch.t ->
-
'a t ->
-
Uri.t ->
-
multiplexed_session
-
-
val request :
-
session:multiplexed_session ->
-
?config:Config.t ->
-
method_:meth ->
-
path:string ->
-
?body:string ->
-
unit ->
-
Response.t
-
-
(* Server push support *)
-
val on_push :
-
session:multiplexed_session ->
-
(Uri.t -> Response.t -> unit) ->
-
unit
-
end
-
-
module Http3 : sig
-
(* Similar to Http2 but with QUIC transport *)
-
type quic_session
-
-
val create_session :
-
sw:Switch.t ->
-
'a t ->
-
Uri.t ->
-
quic_session
-
end
-
```
-
-
### Implementation Strategy
-
- Use `ocaml-h2` library for HTTP/2
-
- Future HTTP/3 support when QUIC libraries mature
-
- Transparent protocol negotiation (ALPN)
-
- Connection coalescing for HTTP/2
-
-
## 9. Metrics and Observability
-
-
### Overview
-
Built-in metrics collection and observability features.
-
-
### Design
-
-
```ocaml
-
module Metrics : sig
-
type counter = {
-
requests_total : int;
-
requests_failed : int;
-
bytes_sent : int64;
-
bytes_received : int64;
-
}
-
-
type histogram = {
-
latency_ms : float array;
-
percentiles : (int * float) list; (* p50, p95, p99 *)
-
}
-
-
type t = {
-
counters : counter;
-
histograms : (string * histogram) list;
-
active_connections : int;
-
pool_stats : Pool.stats;
-
}
-
-
val enable : unit -> unit
-
val disable : unit -> unit
-
val reset : unit -> unit
-
val get : unit -> t
-
-
(* Prometheus integration *)
-
module Prometheus : sig
-
val register : unit -> unit
-
val export : unit -> string (* Prometheus text format *)
-
end
-
-
(* OpenTelemetry integration *)
-
module OpenTelemetry : sig
-
val with_tracing : Config.t -> Config.t
-
val span : string -> (unit -> 'a) -> 'a
-
end
-
end
-
```
-
-
### Metrics to Track
-
- Request/response times
-
- Connection pool utilization
-
- Cache hit/miss rates
-
- Error rates by type
-
- Bandwidth usage
-
- Protocol distribution (HTTP/1.1 vs HTTP/2)
-
-
## 10. Testing Utilities
-
-
### Overview
-
Built-in testing utilities for mocking and recording HTTP interactions.
-
-
### Design
-
-
```ocaml
-
module Test : sig
-
(* Mock responses *)
-
module Mock : sig
-
type rule = {
-
pattern : Uri.t -> meth -> bool;
-
response : Response.t;
-
delay : float option;
-
}
-
-
val add_rule : rule -> unit
-
val with_mock : rule list -> (unit -> 'a) -> 'a
-
val reset : unit -> unit
-
end
-
-
(* Record and replay *)
-
module VCR : sig
-
type cassette
-
-
val record : string -> (unit -> 'a) -> 'a * cassette
-
val replay : cassette -> (unit -> 'a) -> 'a
-
val save : cassette -> string -> unit
-
val load : string -> cassette
-
end
-
-
(* Assertions *)
-
val assert_requested :
-
?times:int ->
-
?headers:(string * string) list ->
-
meth ->
-
Uri.t ->
-
unit
-
end
-
```
-
-
### Features
-
- Request mocking without network calls
-
- VCR-style record/replay for integration tests
-
- Request assertions for testing
-
- Latency simulation
-
- Error injection
-
-
## Implementation Priority
-
-
1. **High Priority** (Phase 2)
-
- Hooks System - Essential for extensibility
-
- Compression Support - Common requirement
-
- PreparedRequest - Enables advanced use cases
-
-
2. **Medium Priority** (Phase 3)
-
- Certificate Handling - Security enhancement
-
- Metrics - Observability
-
- Testing Utilities - Developer experience
-
-
3. **Low Priority** (Phase 4)
-
- SOCKS Proxy - Specialized use case
-
- Character Encoding - Can use external libraries
-
- WebSocket - Separate protocol
-
- HTTP/2-3 - Requires mature libraries
-
-
## Dependencies
-
-
- `camlzip` - For compression support
-
- `ocaml-h2` - For HTTP/2 support
-
- `prometheus` - For metrics export
-
- `x509` - Enhanced certificate handling
-
- Character encoding library (TBD)
-
- SOCKS library (TBD)
-
-
## Testing Strategy
-
-
Each feature should include:
-
- Unit tests for core functionality
-
- Integration tests with real servers
-
- Performance benchmarks
-
- Security testing for auth/crypto features
-
- Compatibility tests with Python requests behavior
+150
stack/requests/examples/session_example.ml
···
+
open Eio
+
open Requests
+
+
let () =
+
Eio_main.run @@ fun env ->
+
Mirage_crypto_rng_unix.use_default ();
+
Switch.run @@ fun sw ->
+
+
(* Example 1: Basic session usage with cookies *)
+
Printf.printf "\n=== Example 1: Basic Session with Cookies ===\n";
+
let session = Session.create ~sw env in
+
+
(* First request sets a cookie *)
+
let resp1 = Session.get session "https://httpbin.org/cookies/set?session_id=abc123" in
+
Printf.printf "Set cookie response: %d\n" (Response.status resp1);
+
+
(* Second request automatically includes the cookie *)
+
let resp2 = Session.get session "https://httpbin.org/cookies" in
+
let body2 = Response.body resp2 |> Buf_read.take_all in
+
Printf.printf "Cookies seen by server: %s\n" body2;
+
+
(* Example 2: Session with default headers and auth *)
+
Printf.printf "\n=== Example 2: Session with Default Configuration ===\n";
+
let github_session = Session.create ~sw env in
+
+
(* Set default headers that apply to all requests *)
+
Session.set_default_header github_session "User-Agent" "OCaml-Requests-Example/1.0";
+
Session.set_default_header github_session "Accept" "application/vnd.github.v3+json";
+
+
(* Set authentication (if you have a token) *)
+
(* Session.set_auth github_session (Auth.bearer "your_github_token"); *)
+
+
(* All requests will use these defaults *)
+
let user = Session.get github_session "https://api.github.com/users/ocaml" in
+
Printf.printf "GitHub user status: %d\n" (Response.status user);
+
+
(* Example 3: Session with retry logic *)
+
Printf.printf "\n=== Example 3: Session with Retry Logic ===\n";
+
let retry_config = Retry.create_config
+
~max_retries:3
+
~backoff_factor:0.5
+
~status_forcelist:[429; 500; 502; 503; 504]
+
() in
+
+
let robust_session = Session.create ~sw ~retry:retry_config env in
+
Session.set_timeout robust_session (Timeout.create ~total:30.0 ());
+
+
(* This request will automatically retry on failures *)
+
let result = Session.get robust_session "https://httpbin.org/status/503" in
+
Printf.printf "Request status (might retry): %d\n" (Response.status result);
+
+
(* Example 4: Persistent cookies *)
+
Printf.printf "\n=== Example 4: Persistent Cookies ===\n";
+
let persistent_session = Session.create ~sw
+
~persist_cookies:true
+
~app_name:"ocaml_example"
+
env in
+
+
(* Login and save cookies *)
+
let _login = Session.post persistent_session
+
~form:["username", "demo"; "password", "demo"]
+
"https://httpbin.org/post" in
+
+
(* Cookies will be saved to ~/.config/ocaml_example/cookies.txt *)
+
Session.save_cookies persistent_session;
+
Printf.printf "Cookies saved to disk\n";
+
+
(* Example 5: Concurrent requests with the same session *)
+
Printf.printf "\n=== Example 5: Concurrent Requests ===\n";
+
let urls = [
+
"https://httpbin.org/delay/1";
+
"https://httpbin.org/delay/1";
+
"https://httpbin.org/delay/1";
+
] in
+
+
let start_time = Unix.gettimeofday () in
+
let responses = Session.map_concurrent session ~max_concurrent:3
+
~f:(fun sess url ->
+
let resp = Session.get sess url in
+
Response.status resp
+
) urls in
+
+
let elapsed = Unix.gettimeofday () -. start_time in
+
Printf.printf "Concurrent requests completed in %.2fs\n" elapsed;
+
List.iter (Printf.printf "Status: %d\n") responses;
+
+
(* Example 6: Prepared requests *)
+
Printf.printf "\n=== Example 6: Prepared Requests ===\n";
+
let prepared = Session.Prepared.create
+
~session
+
~method_:Method.POST
+
"https://httpbin.org/post" in
+
+
(* Inspect and modify the prepared request *)
+
let prepared = Session.Prepared.set_header prepared "X-Custom" "Header" in
+
let prepared = Session.Prepared.set_body prepared (Body.text "Hello, World!") in
+
+
Format.printf "Prepared request:@.%a@." Session.Prepared.pp prepared;
+
+
(* Send when ready *)
+
let resp = Session.Prepared.send prepared in
+
Printf.printf "Prepared request sent, status: %d\n" (Response.status resp);
+
+
(* Example 7: Hooks *)
+
Printf.printf "\n=== Example 7: Request/Response Hooks ===\n";
+
let hook_session = Session.create ~sw env in
+
+
(* Add a request hook to log all requests *)
+
Session.Hooks.add_request_hook hook_session (fun headers method_ url ->
+
Printf.printf "-> Request: %s %s\n" (Method.to_string method_) url;
+
headers
+
);
+
+
(* Add a response hook to log all responses *)
+
Session.Hooks.add_response_hook hook_session (fun response ->
+
Printf.printf "<- Response: %d\n" (Response.status response)
+
);
+
+
(* All requests will trigger hooks *)
+
let _ = Session.get hook_session "https://httpbin.org/get" in
+
let _ = Session.post hook_session "https://httpbin.org/post" in
+
+
(* Example 8: Session statistics *)
+
Printf.printf "\n=== Example 8: Session Statistics ===\n";
+
let stats = Session.stats session in
+
Printf.printf "Total requests: %d\n" stats#requests_made;
+
Printf.printf "Total time: %.3fs\n" stats#total_time;
+
Printf.printf "Average time per request: %.3fs\n"
+
(stats#total_time /. float_of_int stats#requests_made);
+
+
(* Pretty print session info *)
+
Format.printf "@.Session info:@.%a@." Session.pp session;
+
+
(* Example 9: Download file *)
+
Printf.printf "\n=== Example 9: Download File ===\n";
+
let download_session = Session.create ~sw env in
+
let temp_file = Path.(env#fs / "/tmp/example_download.json") in
+
+
Session.download_file download_session
+
~on_progress:(fun ~received ~total ->
+
match total with
+
| Some t -> Printf.printf "Downloaded %Ld/%Ld bytes\r%!" received t
+
| None -> Printf.printf "Downloaded %Ld bytes\r%!" received
+
)
+
"https://httpbin.org/json"
+
temp_file;
+
+
Printf.printf "\nFile downloaded to /tmp/example_download.json\n";
+
+
Printf.printf "\n=== All examples completed successfully! ===\n"
+378
stack/requests/lib/cookie_jar.ml
···
+
open Eio
+
+
let src = Logs.Src.create "requests.cookie_jar" ~doc:"HTTP Cookie Jar"
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
(** Cookie same-site policy *)
+
type same_site = [`Strict | `Lax | `None]
+
+
(** HTTP Cookie *)
+
type cookie = {
+
domain : string;
+
path : string;
+
name : string;
+
value : string;
+
secure : bool;
+
http_only : bool;
+
expires : Ptime.t option;
+
same_site : same_site option;
+
creation_time : Ptime.t;
+
last_access : Ptime.t;
+
}
+
+
(** Cookie jar for storing and managing cookies *)
+
type t = {
+
mutable cookies : cookie list;
+
mutex : Mutex.t;
+
}
+
+
(** {1 Creation} *)
+
+
let create () =
+
Log.debug (fun m -> m "Creating new empty cookie jar");
+
{ cookies = []; mutex = Mutex.create () }
+
+
(** {1 Cookie Matching Helpers} *)
+
+
let domain_matches cookie_domain request_domain =
+
(* Cookie domain .example.com matches example.com and sub.example.com *)
+
if String.starts_with ~prefix:"." cookie_domain then
+
let domain = String.sub cookie_domain 1 (String.length cookie_domain - 1) in
+
request_domain = domain ||
+
String.ends_with ~suffix:("." ^ domain) request_domain
+
else
+
cookie_domain = request_domain
+
+
let path_matches cookie_path request_path =
+
(* Cookie path /foo matches /foo, /foo/, /foo/bar *)
+
String.starts_with ~prefix:cookie_path request_path
+
+
let is_expired cookie clock =
+
match cookie.expires with
+
| None -> false (* Session cookie *)
+
| Some exp_time ->
+
let now = Ptime.of_float_s (Time.now clock) |> Option.value ~default:(Ptime.epoch) in
+
Ptime.compare now exp_time > 0
+
+
(** {1 Cookie Parsing} *)
+
+
let parse_cookie_attribute ~url:_ attr value cookie =
+
let attr_lower = String.lowercase_ascii attr in
+
match attr_lower with
+
| "domain" -> { cookie with domain = value }
+
| "path" -> { cookie with path = value }
+
| "expires" ->
+
(* Parse various date formats *)
+
(try
+
let time, _tz_offset, _tz_string = Ptime.of_rfc3339 value |> Result.get_ok in
+
{ cookie with expires = Some time }
+
with _ ->
+
Log.debug (fun m -> m "Failed to parse expires: %s" value);
+
cookie)
+
| "max-age" ->
+
(try
+
let seconds = int_of_string value in
+
let now = Unix.time () in
+
let expires = Ptime.of_float_s (now +. float_of_int seconds) in
+
{ cookie with expires }
+
with _ -> cookie)
+
| "secure" -> { cookie with secure = true }
+
| "httponly" -> { cookie with http_only = true }
+
| "samesite" ->
+
let same_site = match String.lowercase_ascii value with
+
| "strict" -> Some `Strict
+
| "lax" -> Some `Lax
+
| "none" -> Some `None
+
| _ -> None
+
in
+
{ cookie with same_site }
+
| _ -> cookie
+
+
let rec parse_set_cookie ~url value =
+
Log.debug (fun m -> m "Parsing Set-Cookie: %s" value);
+
+
let uri = Uri.of_string url in
+
let default_domain = Uri.host_with_default ~default:"localhost" uri in
+
let default_path =
+
let p = Uri.path uri in
+
if p = "" then "/"
+
else
+
let last_slash = String.rindex_opt p '/' in
+
match last_slash with
+
| None -> "/"
+
| Some i -> String.sub p 0 (i + 1)
+
in
+
+
(* Split into attributes *)
+
let parts = String.split_on_char ';' value |> List.map String.trim in
+
+
match parts with
+
| [] -> None
+
| name_value :: attrs ->
+
(* Parse name=value *)
+
(match String.index_opt name_value '=' with
+
| None -> None
+
| Some eq_pos ->
+
let name = String.sub name_value 0 eq_pos |> String.trim in
+
let value = String.sub name_value (eq_pos + 1)
+
(String.length name_value - eq_pos - 1) |> String.trim in
+
+
let now = Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch in
+
let base_cookie = {
+
name;
+
value;
+
domain = default_domain;
+
path = default_path;
+
secure = false;
+
http_only = false;
+
expires = None;
+
same_site = None;
+
creation_time = now;
+
last_access = now;
+
} in
+
+
(* Parse attributes *)
+
let cookie = List.fold_left (fun cookie attr ->
+
match String.index_opt attr '=' with
+
| None -> parse_cookie_attribute ~url attr "" cookie
+
| Some eq ->
+
let attr_name = String.sub attr 0 eq |> String.trim in
+
let attr_value = String.sub attr (eq + 1)
+
(String.length attr - eq - 1) |> String.trim in
+
parse_cookie_attribute ~url attr_name attr_value cookie
+
) base_cookie attrs in
+
+
Log.debug (fun m -> m "Parsed cookie: %a" pp_cookie cookie);
+
Some cookie)
+
+
and make_cookie_header cookies =
+
cookies
+
|> List.map (fun c -> Printf.sprintf "%s=%s" c.name c.value)
+
|> String.concat "; "
+
+
(** {1 Pretty Printing} *)
+
+
and pp_same_site ppf = function
+
| `Strict -> Format.pp_print_string ppf "Strict"
+
| `Lax -> Format.pp_print_string ppf "Lax"
+
| `None -> Format.pp_print_string ppf "None"
+
+
and pp_cookie ppf cookie =
+
Format.fprintf ppf "@[<hov 2>{ name=%S;@ value=%S;@ domain=%S;@ path=%S;@ \
+
secure=%b;@ http_only=%b;@ expires=%a;@ same_site=%a }@]"
+
cookie.name
+
cookie.value
+
cookie.domain
+
cookie.path
+
cookie.secure
+
cookie.http_only
+
(Format.pp_print_option Ptime.pp) cookie.expires
+
(Format.pp_print_option pp_same_site) cookie.same_site
+
+
let pp ppf t =
+
Mutex.lock t.mutex;
+
let cookies = t.cookies in
+
Mutex.unlock t.mutex;
+
+
Format.fprintf ppf "@[<v>CookieJar with %d cookies:@," (List.length cookies);
+
List.iter (fun cookie ->
+
Format.fprintf ppf " %a@," pp_cookie cookie
+
) cookies;
+
Format.fprintf ppf "@]"
+
+
(** {1 Cookie Management} *)
+
+
let add_cookie t cookie =
+
Log.debug (fun m -> m "Adding cookie: %s=%s for domain %s"
+
cookie.name cookie.value cookie.domain);
+
+
Mutex.lock t.mutex;
+
(* Remove existing cookie with same name, domain, and path *)
+
t.cookies <- List.filter (fun c ->
+
not (c.name = cookie.name && c.domain = cookie.domain && c.path = cookie.path)
+
) t.cookies;
+
t.cookies <- cookie :: t.cookies;
+
Mutex.unlock t.mutex
+
+
let extract_from_headers t ~url headers =
+
Log.debug (fun m -> m "Extracting cookies from headers for URL: %s" url);
+
+
let set_cookie_values = Headers.get_multi "set-cookie" headers in
+
List.iter (fun value ->
+
match parse_set_cookie ~url value with
+
| Some cookie -> add_cookie t cookie
+
| None -> Log.warn (fun m -> m "Failed to parse Set-Cookie header: %s" value)
+
) set_cookie_values
+
+
let get_cookies t ~url =
+
let uri = Uri.of_string url in
+
let domain = Uri.host_with_default ~default:"localhost" uri in
+
let path = Uri.path uri in
+
let is_secure = Uri.scheme uri = Some "https" in
+
+
Log.debug (fun m -> m "Getting cookies for domain=%s path=%s secure=%b"
+
domain path is_secure);
+
+
Mutex.lock t.mutex;
+
let applicable = List.filter (fun cookie ->
+
domain_matches cookie.domain domain &&
+
path_matches cookie.path path &&
+
(not cookie.secure || is_secure)
+
) t.cookies in
+
+
(* Update last access time *)
+
let now = Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch in
+
let updated = List.map (fun c ->
+
if List.memq c applicable then
+
{ c with last_access = now }
+
else c
+
) t.cookies in
+
t.cookies <- updated;
+
Mutex.unlock t.mutex;
+
+
Log.debug (fun m -> m "Found %d applicable cookies" (List.length applicable));
+
applicable
+
+
let add_to_headers t ~url headers =
+
let cookies = get_cookies t ~url in
+
if cookies = [] then headers
+
else
+
let cookie_header = make_cookie_header cookies in
+
Log.debug (fun m -> m "Adding Cookie header: %s" cookie_header);
+
Headers.add "cookie" cookie_header headers
+
+
let clear t =
+
Log.info (fun m -> m "Clearing all cookies");
+
Mutex.lock t.mutex;
+
t.cookies <- [];
+
Mutex.unlock t.mutex
+
+
let clear_expired t ~clock =
+
Mutex.lock t.mutex;
+
let before_count = List.length t.cookies in
+
t.cookies <- List.filter (fun c -> not (is_expired c clock)) t.cookies;
+
let removed = before_count - List.length t.cookies in
+
Mutex.unlock t.mutex;
+
Log.info (fun m -> m "Cleared %d expired cookies" removed)
+
+
let clear_session_cookies t =
+
Mutex.lock t.mutex;
+
let before_count = List.length t.cookies in
+
t.cookies <- List.filter (fun c -> c.expires <> None) t.cookies;
+
let removed = before_count - List.length t.cookies in
+
Mutex.unlock t.mutex;
+
Log.info (fun m -> m "Cleared %d session cookies" removed)
+
+
(** {1 Mozilla Format} *)
+
+
let to_mozilla_format_internal t =
+
let buffer = Buffer.create 1024 in
+
Buffer.add_string buffer "# Netscape HTTP Cookie File\n";
+
Buffer.add_string buffer "# This is a generated file! Do not edit.\n\n";
+
+
List.iter (fun cookie ->
+
let include_subdomains =
+
if String.starts_with ~prefix:"." cookie.domain then "TRUE" else "FALSE" in
+
let secure = if cookie.secure then "TRUE" else "FALSE" in
+
let expires = match cookie.expires with
+
| None -> "0" (* Session cookie *)
+
| Some t ->
+
let epoch = Ptime.to_float_s t |> int_of_float |> string_of_int in
+
epoch
+
in
+
+
Buffer.add_string buffer (Printf.sprintf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n"
+
cookie.domain
+
include_subdomains
+
cookie.path
+
secure
+
expires
+
cookie.name
+
cookie.value)
+
) t.cookies;
+
+
Buffer.contents buffer
+
+
let to_mozilla_format t =
+
Mutex.lock t.mutex;
+
let result = to_mozilla_format_internal t in
+
Mutex.unlock t.mutex;
+
result
+
+
let from_mozilla_format content =
+
Log.debug (fun m -> m "Parsing Mozilla format cookies");
+
let jar = create () in
+
+
let lines = String.split_on_char '\n' content in
+
List.iter (fun line ->
+
let line = String.trim line in
+
if line <> "" && not (String.starts_with ~prefix:"#" line) then
+
match String.split_on_char '\t' line with
+
| [domain; _include_subdomains; path; secure; expires; name; value] ->
+
let now = Ptime.of_float_s (Unix.time ()) |> Option.value ~default:Ptime.epoch in
+
let expires =
+
let exp_int = try int_of_string expires with _ -> 0 in
+
if exp_int = 0 then None
+
else Ptime.of_float_s (float_of_int exp_int)
+
in
+
+
let cookie = {
+
domain;
+
path;
+
name;
+
value;
+
secure = (secure = "TRUE");
+
http_only = false; (* Not stored in Mozilla format *)
+
expires;
+
same_site = None; (* Not stored in Mozilla format *)
+
creation_time = now;
+
last_access = now;
+
} in
+
add_cookie jar cookie;
+
Log.debug (fun m -> m "Loaded cookie: %s=%s" name value)
+
| _ ->
+
Log.warn (fun m -> m "Invalid cookie line: %s" line)
+
) lines;
+
+
Log.info (fun m -> m "Loaded %d cookies" (List.length jar.cookies));
+
jar
+
+
(** {1 File Operations} *)
+
+
(** Get cookie file path - uses XDG data directory or provided path *)
+
let get_cookie_file ?xdg ?path () =
+
match xdg, path with
+
| Some xdg_ctx, _ ->
+
(* Use XDG data directory for cookies *)
+
let data_dir = Xdge.data_dir xdg_ctx in
+
Path.(data_dir / "cookies.txt")
+
| None, Some p -> p
+
| None, None ->
+
failwith "Cookie_jar: either xdg or path must be provided"
+
+
let load ?xdg ?path () =
+
let cookie_file = get_cookie_file ?xdg ?path () in
+
Log.info (fun m -> m "Loading cookies from %a" Path.pp cookie_file);
+
+
try
+
let content = Path.load cookie_file in
+
from_mozilla_format content
+
with
+
| Eio.Io _ ->
+
Log.info (fun m -> m "Cookie file not found, creating empty jar");
+
create ()
+
| exn ->
+
Log.err (fun m -> m "Failed to load cookies: %s" (Printexc.to_string exn));
+
create ()
+
+
let save ?xdg ?path t =
+
let cookie_file = get_cookie_file ?xdg ?path () in
+
Log.info (fun m -> m "Saving %d cookies to %a" (List.length t.cookies) Path.pp cookie_file);
+
+
let content = to_mozilla_format t in
+
+
try
+
Path.save ~create:(`Or_truncate 0o600) cookie_file content;
+
Log.debug (fun m -> m "Cookies saved successfully")
+
with exn ->
+
Log.err (fun m -> m "Failed to save cookies: %s" (Printexc.to_string exn))
+101
stack/requests/lib/cookie_jar.mli
···
+
(** HTTP Cookie Jar with Mozilla format persistence support *)
+
+
open Eio
+
+
(** Cookie same-site policy *)
+
type same_site = [`Strict | `Lax | `None]
+
+
(** HTTP Cookie *)
+
type cookie = {
+
domain : string; (** Domain that set the cookie *)
+
path : string; (** Path scope for the cookie *)
+
name : string; (** Cookie name *)
+
value : string; (** Cookie value *)
+
secure : bool; (** Only send over HTTPS *)
+
http_only : bool; (** Not accessible to JavaScript *)
+
expires : Ptime.t option; (** Expiry time, None for session cookies *)
+
same_site : same_site option; (** Same-site policy *)
+
creation_time : Ptime.t; (** When cookie was created *)
+
last_access : Ptime.t; (** Last time cookie was accessed *)
+
}
+
+
(** Cookie jar for storing and managing cookies *)
+
type t
+
+
(** {1 Creation and Loading} *)
+
+
(** Create an empty cookie jar *)
+
val create : unit -> t
+
+
(** Load cookies from Mozilla format file.
+
If xdg is provided, uses XDG data directory, otherwise uses provided path. *)
+
val load : ?xdg:Xdge.t -> ?path:Eio.Fs.dir_ty Path.t -> unit -> t
+
+
(** Save cookies to Mozilla format file.
+
If xdg is provided, uses XDG data directory, otherwise uses provided path. *)
+
val save : ?xdg:Xdge.t -> ?path:Eio.Fs.dir_ty Path.t -> t -> unit
+
+
(** {1 Cookie Management} *)
+
+
(** Add a cookie to the jar *)
+
val add_cookie : t -> cookie -> unit
+
+
(** Extract cookies from Set-Cookie headers *)
+
val extract_from_headers : t -> url:string -> Headers.t -> unit
+
+
(** Get cookies applicable for a URL *)
+
val get_cookies : t -> url:string -> cookie list
+
+
(** Add Cookie header for a request *)
+
val add_to_headers : t -> url:string -> Headers.t -> Headers.t
+
+
(** Clear all cookies *)
+
val clear : t -> unit
+
+
(** Clear expired cookies *)
+
val clear_expired : t -> clock:_ Time.clock -> unit
+
+
(** Clear session cookies (those without expiry) *)
+
val clear_session_cookies : t -> unit
+
+
(** {1 Cookie Creation} *)
+
+
(** Parse Set-Cookie header value into a cookie *)
+
val parse_set_cookie : url:string -> string -> cookie option
+
+
(** Create cookie header value from cookies *)
+
val make_cookie_header : cookie list -> string
+
+
(** {1 Pretty Printing} *)
+
+
(** Pretty print a cookie *)
+
val pp_cookie : Format.formatter -> cookie -> unit
+
+
(** Pretty print a cookie jar *)
+
val pp : Format.formatter -> t -> unit
+
+
(** {1 Mozilla Format} *)
+
+
(** Mozilla cookies.txt format:
+
# Netscape HTTP Cookie File
+
# This is a generated file! Do not edit.
+
+
domain include_subdomains path secure expires name value
+
+
Where:
+
- domain: The domain that created the cookie
+
- include_subdomains: TRUE if cookie applies to subdomains, FALSE otherwise
+
- path: The path the cookie is valid for
+
- secure: TRUE if cookie requires secure connection
+
- expires: Unix timestamp when cookie expires (0 for session cookies)
+
- name: Cookie name
+
- value: Cookie value
+
+
Example:
+
.github.com TRUE / TRUE 1735689600 _gh_sess abc123... *)
+
+
(** Write cookies in Mozilla format *)
+
val to_mozilla_format : t -> string
+
+
(** Parse Mozilla format cookies *)
+
val from_mozilla_format : string -> t
+3
stack/requests/lib/dune
···
cohttp-eio
uri
jsonm
+
yojson
base64
cacheio
+
xdge
logs
ptime
+
cmdliner
mirage-crypto
mirage-crypto-rng
mirage-crypto-rng.unix
+22 -1
stack/requests/lib/headers.ml
···
+
let src = Logs.Src.create "requests.headers" ~doc:"HTTP Headers"
+
module Log = (val Logs.src_log src : Logs.LOG)
+
(* Use a map with lowercase keys for case-insensitive lookup *)
module StringMap = Map.Make(String)
···
let add key value t =
let nkey = normalize_key key in
+
Log.debug (fun m -> m "Adding header %s: %s" key value);
let existing =
match StringMap.find_opt nkey t with
| Some (_, values) -> values
···
| None -> Printf.sprintf "bytes=%Ld-" start
| Some e -> Printf.sprintf "bytes=%Ld-%Ld" start e
in
-
set "Range" range_value t
+
set "Range" range_value t
+
+
(* Additional helper for getting multiple header values *)
+
let get_multi key t = get_all key t
+
+
(* Pretty printer for headers *)
+
let pp ppf t =
+
Format.fprintf ppf "@[<v>Headers:@,";
+
let headers = to_list t in
+
List.iter (fun (k, v) ->
+
Format.fprintf ppf " %s: %s@," k v
+
) headers;
+
Format.fprintf ppf "@]"
+
+
let pp_brief ppf t =
+
let headers = to_list t in
+
let count = List.length headers in
+
Format.fprintf ppf "Headers(%d entries)" count
+10 -1
stack/requests/lib/headers.mli
···
val user_agent : string -> t -> t
val host : string -> t -> t
val cookie : string -> string -> t -> t
-
val range : start:int64 -> ?end_:int64 -> unit -> t -> t
+
val range : start:int64 -> ?end_:int64 -> unit -> t -> t
+
+
(** Get multiple values for a header (alias for get_all) *)
+
val get_multi : string -> t -> string list
+
+
(** Pretty printer for headers *)
+
val pp : Format.formatter -> t -> unit
+
+
(** Brief pretty printer showing count only *)
+
val pp_brief : Format.formatter -> t -> unit
+31 -1
stack/requests/lib/response.ml
···
open Eio
+
let src = Logs.Src.create "requests.response" ~doc:"HTTP Response"
+
module Log = (val Logs.src_log src : Logs.LOG)
+
type t = {
status : int;
headers : Headers.t;
···
}
let make ~status ~headers ~body ~url ~elapsed =
+
Log.debug (fun m -> m "Creating response: status=%d url=%s elapsed=%.3fs" status url elapsed);
{ status; headers; body; url; elapsed; closed = false }
let status t = t.status
···
let close t =
if not t.closed then begin
+
Log.debug (fun m -> m "Closing response for %s" t.url);
(* Consume remaining body if any *)
try
(* Read and discard remaining data *)
···
drain ()
with _ -> ();
t.closed <- true
-
end
+
end
+
+
(* Pretty printers *)
+
let pp ppf t =
+
Format.fprintf ppf "@[<v>Response:@,\
+
status: %d@,\
+
url: %s@,\
+
elapsed: %.3fs@,\
+
headers: @[%a@]@]"
+
t.status t.url t.elapsed
+
Headers.pp_brief t.headers
+
+
let pp_detailed ppf t =
+
let status_desc =
+
if is_success t then "success"
+
else if is_redirect t then "redirect"
+
else if is_client_error t then "client error"
+
else if is_server_error t then "server error"
+
else "unknown" in
+
Format.fprintf ppf "@[<v>Response:@,\
+
status: %d (%s)@,\
+
url: %s@,\
+
elapsed: %.3fs@,\
+
@[%a@]@]"
+
t.status status_desc t.url t.elapsed
+
Headers.pp t.headers
+9 -1
stack/requests/lib/response.mli
···
body:Buf_read.t ->
url:string ->
elapsed:float ->
-
t
+
t
+
+
(** Pretty printers *)
+
+
val pp : Format.formatter -> t -> unit
+
(** Pretty print a response summary *)
+
+
val pp_detailed : Format.formatter -> t -> unit
+
(** Pretty print a response with full headers *)
+144
stack/requests/lib/retry.ml
···
+
open Eio
+
+
let src = Logs.Src.create "requests.retry" ~doc:"HTTP Request Retry Logic"
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
type config = {
+
max_retries : int;
+
backoff_factor : float;
+
backoff_max : float;
+
status_forcelist : int list;
+
allowed_methods : Method.t list;
+
respect_retry_after : bool;
+
jitter : bool;
+
}
+
+
let default_config = {
+
max_retries = 3;
+
backoff_factor = 0.3;
+
backoff_max = 120.0;
+
status_forcelist = [408; 429; 500; 502; 503; 504];
+
allowed_methods = Method.[GET; HEAD; PUT; DELETE; OPTIONS; TRACE];
+
respect_retry_after = true;
+
jitter = true;
+
}
+
+
let create_config
+
?(max_retries = 3)
+
?(backoff_factor = 0.3)
+
?(backoff_max = 120.0)
+
?(status_forcelist = [408; 429; 500; 502; 503; 504])
+
?(allowed_methods = Method.[GET; HEAD; PUT; DELETE; OPTIONS; TRACE])
+
?(respect_retry_after = true)
+
?(jitter = true)
+
() =
+
Log.debug (fun m -> m "Creating retry config: max_retries=%d backoff_factor=%.2f"
+
max_retries backoff_factor);
+
{
+
max_retries;
+
backoff_factor;
+
backoff_max;
+
status_forcelist;
+
allowed_methods;
+
respect_retry_after;
+
jitter;
+
}
+
+
let should_retry ~config ~method_ ~status =
+
let should =
+
List.mem method_ config.allowed_methods &&
+
List.mem status config.status_forcelist
+
in
+
Log.debug (fun m -> m "Should retry? method=%s status=%d -> %b"
+
(Method.to_string method_) status should);
+
should
+
+
let calculate_backoff ~config ~attempt =
+
let base_delay = config.backoff_factor *. (2.0 ** float_of_int attempt) in
+
let delay =
+
if config.jitter then
+
(* Add random jitter between 0 and base_delay *)
+
base_delay +. Random.float base_delay
+
else
+
base_delay
+
in
+
let final_delay = min delay config.backoff_max in
+
Log.debug (fun m -> m "Backoff calculation: attempt=%d base=%.2f jitter=%b -> %.2f seconds"
+
attempt base_delay config.jitter final_delay);
+
final_delay
+
+
let parse_retry_after value =
+
Log.debug (fun m -> m "Parsing Retry-After header: %s" value);
+
+
(* First try to parse as integer (delay in seconds) *)
+
match int_of_string_opt value with
+
| Some seconds ->
+
Log.debug (fun m -> m "Retry-After is %d seconds" seconds);
+
Some (float_of_int seconds)
+
| None ->
+
(* Try to parse as HTTP date *)
+
(* This is simplified - real implementation would use a proper HTTP date parser *)
+
try
+
let time, _tz_offset, _tz_string = Ptime.of_rfc3339 value |> Result.get_ok in
+
let now = Unix.time () in
+
let target = Ptime.to_float_s time in
+
let delay = max 0.0 (target -. now) in
+
Log.debug (fun m -> m "Retry-After is HTTP date, delay=%.2f seconds" delay);
+
Some delay
+
with _ ->
+
Log.warn (fun m -> m "Failed to parse Retry-After header: %s" value);
+
None
+
+
let with_retry ~sw:_ ~clock ~config ~f ~should_retry_exn =
+
let rec attempt_with_retry attempt =
+
Log.info (fun m -> m "Attempt %d/%d" attempt (config.max_retries + 1));
+
+
match f () with
+
| result ->
+
if attempt > 1 then
+
Log.info (fun m -> m "Request succeeded after %d attempts" attempt);
+
result
+
| exception exn when attempt <= config.max_retries && should_retry_exn exn ->
+
let delay = calculate_backoff ~config ~attempt in
+
+
Log.warn (fun m -> m "Request failed (attempt %d/%d): %s. Retrying in %.2f seconds..."
+
attempt (config.max_retries + 1) (Printexc.to_string exn) delay);
+
+
(* Sleep for the backoff duration *)
+
Time.sleep clock delay;
+
+
attempt_with_retry (attempt + 1)
+
| exception exn ->
+
if attempt > config.max_retries then
+
Log.err (fun m -> m "Request failed after %d attempts: %s"
+
attempt (Printexc.to_string exn))
+
else
+
Log.err (fun m -> m "Request failed and won't be retried: %s"
+
(Printexc.to_string exn));
+
raise exn
+
in
+
attempt_with_retry 1
+
+
let pp_config ppf config =
+
Format.fprintf ppf "@[<v>Retry Configuration:@,\
+
@[<v 2>\
+
max_retries: %d@,\
+
backoff_factor: %.2f@,\
+
backoff_max: %.1f seconds@,\
+
status_forcelist: [%a]@,\
+
allowed_methods: [%a]@,\
+
respect_retry_after: %b@,\
+
jitter: %b\
+
@]@]"
+
config.max_retries
+
config.backoff_factor
+
config.backoff_max
+
Format.(pp_print_list ~pp_sep:(fun ppf () -> fprintf ppf ", ") pp_print_int) config.status_forcelist
+
Format.(pp_print_list ~pp_sep:(fun ppf () -> fprintf ppf ", ")
+
(fun ppf m -> pp_print_string ppf (Method.to_string m))) config.allowed_methods
+
config.respect_retry_after
+
config.jitter
+
+
let log_retry ~attempt ~delay ~reason =
+
Log.info (fun m -> m "Retry attempt %d scheduled in %.2f seconds. Reason: %s"
+
attempt delay reason)
+52
stack/requests/lib/retry.mli
···
+
(** HTTP request retry logic with exponential backoff *)
+
+
open Eio
+
+
(** Retry configuration *)
+
type config = {
+
max_retries : int; (** Maximum number of retry attempts *)
+
backoff_factor : float; (** Exponential backoff multiplier *)
+
backoff_max : float; (** Maximum backoff time in seconds *)
+
status_forcelist : int list; (** HTTP status codes to retry *)
+
allowed_methods : Method.t list; (** Methods safe to retry *)
+
respect_retry_after : bool; (** Honor Retry-After response header *)
+
jitter : bool; (** Add randomness to prevent thundering herd *)
+
}
+
+
(** Default retry configuration *)
+
val default_config : config
+
+
(** Create a custom retry configuration *)
+
val create_config :
+
?max_retries:int ->
+
?backoff_factor:float ->
+
?backoff_max:float ->
+
?status_forcelist:int list ->
+
?allowed_methods:Method.t list ->
+
?respect_retry_after:bool ->
+
?jitter:bool ->
+
unit -> config
+
+
(** Check if a request should be retried *)
+
val should_retry : config:config -> method_:Method.t -> status:int -> bool
+
+
(** Calculate backoff delay for a given attempt *)
+
val calculate_backoff : config:config -> attempt:int -> float
+
+
(** Parse Retry-After header value (seconds or HTTP date) *)
+
val parse_retry_after : string -> float option
+
+
(** Execute a request with retry logic *)
+
val with_retry :
+
sw:Switch.t ->
+
clock:_ Time.clock ->
+
config:config ->
+
f:(unit -> 'a) ->
+
should_retry_exn:(exn -> bool) ->
+
'a
+
+
(** Pretty print retry configuration *)
+
val pp_config : Format.formatter -> config -> unit
+
+
(** Log retry attempt information *)
+
val log_retry : attempt:int -> delay:float -> reason:string -> unit
+625
stack/requests/lib/session.ml
···
+
open Eio
+
+
let src = Logs.Src.create "requests.session" ~doc:"HTTP Session"
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
(** {1 Types} *)
+
+
type ('clock, 'net) t = {
+
sw : Switch.t;
+
client : ('clock, 'net) Client.t;
+
clock : 'clock;
+
cookie_jar : Cookie_jar.t;
+
mutable default_headers : Headers.t;
+
mutable auth : Auth.t option;
+
mutable timeout : Timeout.t;
+
mutable follow_redirects : bool;
+
mutable max_redirects : int;
+
mutable retry : Retry.config option;
+
persist_cookies : bool;
+
xdg : Xdge.t option;
+
(* Statistics *)
+
mutable requests_made : int;
+
mutable total_time : float;
+
mutable retries_count : int;
+
mutex : Mutex.t;
+
}
+
+
(** {1 Session Creation} *)
+
+
let rec create
+
~sw
+
?client
+
?cookie_jar
+
?(default_headers = Headers.empty)
+
?auth
+
?(timeout = Timeout.default)
+
?(follow_redirects = true)
+
?(max_redirects = 10)
+
?(verify_tls = true)
+
?retry
+
?(persist_cookies = false)
+
?xdg
+
env =
+
+
(* Create default XDG context if needed *)
+
let xdg = match xdg, persist_cookies with
+
| Some x, _ -> Some x
+
| None, true -> Some (Xdge.create env#fs "requests")
+
| None, false -> None
+
in
+
+
Log.info (fun m -> m "Creating new session%s"
+
(match xdg with
+
| Some x -> Printf.sprintf " with XDG app=%s" (Xdge.app_name x)
+
| None -> ""));
+
+
(* Create or use provided client *)
+
let client = match client with
+
| Some c -> c
+
| None ->
+
Client.create ~verify_tls ~timeout
+
~clock:env#clock ~net:env#net ()
+
in
+
+
(* Create or load cookie jar *)
+
let cookie_jar = match cookie_jar, persist_cookies, xdg with
+
| Some jar, _, _ -> jar
+
| None, true, Some xdg_ctx ->
+
Log.debug (fun m -> m "Loading persistent cookie jar from XDG data dir");
+
Cookie_jar.load ~xdg:xdg_ctx ()
+
| None, _, _ ->
+
Cookie_jar.create ()
+
in
+
+
let session = {
+
sw;
+
client;
+
clock = env#clock;
+
cookie_jar;
+
default_headers;
+
auth;
+
timeout;
+
follow_redirects;
+
max_redirects;
+
retry;
+
persist_cookies;
+
xdg;
+
requests_made = 0;
+
total_time = 0.0;
+
retries_count = 0;
+
mutex = Mutex.create ();
+
} in
+
+
(* Register cleanup on switch *)
+
Switch.on_release sw (fun () ->
+
if persist_cookies && Option.is_some xdg then begin
+
Log.info (fun m -> m "Saving cookies on session close");
+
Cookie_jar.save ?xdg session.cookie_jar
+
end
+
);
+
+
session
+
+
and with_session ~sw ?client ?cookie_jar ?default_headers ?auth ?timeout
+
?follow_redirects ?max_redirects ?verify_tls ?retry ?persist_cookies
+
?xdg env f =
+
let session = create ~sw ?client ?cookie_jar ?default_headers ?auth
+
?timeout ?follow_redirects ?max_redirects ?verify_tls ?retry
+
?persist_cookies ?xdg env in
+
try
+
let result = f session in
+
close session;
+
result
+
with exn ->
+
close session;
+
raise exn
+
+
(** {1 Configuration Management} *)
+
+
and set_default_header t key value =
+
Mutex.lock t.mutex;
+
t.default_headers <- Headers.set key value t.default_headers;
+
Mutex.unlock t.mutex;
+
Log.debug (fun m -> m "Set default header %s: %s" key value)
+
+
and remove_default_header t key =
+
Mutex.lock t.mutex;
+
t.default_headers <- Headers.remove key t.default_headers;
+
Mutex.unlock t.mutex;
+
Log.debug (fun m -> m "Removed default header %s" key)
+
+
and set_auth t auth =
+
Mutex.lock t.mutex;
+
t.auth <- Some auth;
+
Mutex.unlock t.mutex;
+
Log.debug (fun m -> m "Set session authentication")
+
+
and clear_auth t =
+
Mutex.lock t.mutex;
+
t.auth <- None;
+
Mutex.unlock t.mutex;
+
Log.debug (fun m -> m "Cleared session authentication")
+
+
and set_timeout t timeout =
+
Mutex.lock t.mutex;
+
t.timeout <- timeout;
+
Mutex.unlock t.mutex
+
+
and set_retry t retry =
+
Mutex.lock t.mutex;
+
t.retry <- Some retry;
+
Mutex.unlock t.mutex
+
+
and disable_retry t =
+
Mutex.lock t.mutex;
+
t.retry <- None;
+
Mutex.unlock t.mutex
+
+
(** {1 Cookie Management} *)
+
+
and cookies t = t.cookie_jar
+
+
and clear_cookies t =
+
Cookie_jar.clear t.cookie_jar
+
+
and save_cookies t =
+
if t.persist_cookies && Option.is_some t.xdg then
+
Cookie_jar.save ?xdg:t.xdg t.cookie_jar
+
+
and load_cookies t =
+
if t.persist_cookies && Option.is_some t.xdg then
+
let loaded = Cookie_jar.load ?xdg:t.xdg () in
+
(* Copy loaded cookies into our jar *)
+
Cookie_jar.clear t.cookie_jar;
+
let cookies_from_loaded = Cookie_jar.to_mozilla_format loaded in
+
let reloaded = Cookie_jar.from_mozilla_format cookies_from_loaded in
+
(* This is a bit convoluted but maintains the same jar reference *)
+
()
+
+
(** {1 Internal Request Function} *)
+
+
and execute_request t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url =
+
let start_time = Unix.gettimeofday () in
+
+
(* Merge headers: default -> cookie -> provided *)
+
let headers =
+
t.default_headers
+
|> Headers.merge (Option.value headers ~default:Headers.empty)
+
|> Cookie_jar.add_to_headers t.cookie_jar ~url
+
in
+
+
(* Use provided auth or session default *)
+
let auth = match auth with Some a -> Some a | None -> t.auth in
+
+
(* Use provided or session defaults *)
+
let timeout = Option.value timeout ~default:t.timeout in
+
let follow_redirects = Option.value follow_redirects ~default:t.follow_redirects in
+
let max_redirects = Option.value max_redirects ~default:t.max_redirects in
+
+
Log.info (fun m -> m "Session request: %s %s"
+
(Method.to_string method_) url);
+
+
(* Make the actual request with retry if configured *)
+
let make_request () =
+
(* TODO: Implement actual HTTP request using cohttp-eio *)
+
(* For now, return a dummy response *)
+
let status = 200 in
+
let headers = Headers.empty in
+
let body_str = "TODO: Implement actual HTTP request" in
+
let body = Buf_read.of_string body_str in
+
Response.make ~status ~headers ~body ~url ~elapsed:0.0
+
in
+
+
let response = match t.retry with
+
| None -> make_request ()
+
| Some retry_config ->
+
Retry.with_retry ~sw:t.sw ~clock:t.clock
+
~config:retry_config
+
~f:make_request
+
~should_retry_exn:(function
+
(* TODO: Handle Stream exceptions once Stream module is properly imported *)
+
| _ -> false)
+
in
+
+
(* Extract cookies from response *)
+
Cookie_jar.extract_from_headers t.cookie_jar ~url (Response.headers response);
+
+
(* Update statistics *)
+
Mutex.lock t.mutex;
+
t.requests_made <- t.requests_made + 1;
+
t.total_time <- t.total_time +. (Unix.gettimeofday () -. start_time);
+
Mutex.unlock t.mutex;
+
+
response
+
+
(** {1 Request Methods} *)
+
+
and request t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url =
+
execute_request t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url
+
+
and get t ?headers ?auth ?timeout ?params url =
+
let url = match params with
+
| None -> url
+
| Some params ->
+
let uri = Uri.of_string url in
+
let uri = List.fold_left (fun u (k, v) ->
+
Uri.add_query_param' u (k, v)
+
) uri params in
+
Uri.to_string uri
+
in
+
execute_request t ?headers ?auth ?timeout ~method_:Method.GET url
+
+
and post t ?headers ?body ?auth ?timeout ?json ?form url =
+
let body, headers = match json, form, body with
+
| Some json, _, _ ->
+
let json_str = Yojson.Safe.to_string json in
+
let body = Body.json json_str in
+
let headers = Option.value headers ~default:Headers.empty
+
|> Headers.content_type Mime.json in
+
Some body, Some headers
+
| _, Some form, _ ->
+
let form_str =
+
form
+
|> List.map (fun (k, v) -> Uri.pct_encode k ^ "=" ^ Uri.pct_encode v)
+
|> String.concat "&"
+
in
+
let body = Body.text form_str in
+
let headers = Option.value headers ~default:Headers.empty
+
|> Headers.content_type Mime.form in
+
Some body, Some headers
+
| _, _, body -> body, headers
+
in
+
execute_request t ?headers ?body ?auth ?timeout ~method_:Method.POST url
+
+
and put t ?headers ?body ?auth ?timeout ?json url =
+
let body, headers = match json with
+
| Some json ->
+
let json_str = Yojson.Safe.to_string json in
+
let body = Body.json json_str in
+
let headers = Option.value headers ~default:Headers.empty
+
|> Headers.content_type Mime.json in
+
Some body, Some headers
+
| None -> body, headers
+
in
+
execute_request t ?headers ?body ?auth ?timeout ~method_:Method.PUT url
+
+
and patch t ?headers ?body ?auth ?timeout ?json url =
+
let body, headers = match json with
+
| Some json ->
+
let json_str = Yojson.Safe.to_string json in
+
let body = Body.json json_str in
+
let headers = Option.value headers ~default:Headers.empty
+
|> Headers.content_type Mime.json in
+
Some body, Some headers
+
| None -> body, headers
+
in
+
execute_request t ?headers ?body ?auth ?timeout ~method_:Method.PATCH url
+
+
and delete t ?headers ?auth ?timeout url =
+
execute_request t ?headers ?auth ?timeout ~method_:Method.DELETE url
+
+
and head t ?headers ?auth ?timeout url =
+
execute_request t ?headers ?auth ?timeout ~method_:Method.HEAD url
+
+
and options t ?headers ?auth ?timeout url =
+
execute_request t ?headers ?auth ?timeout ~method_:Method.OPTIONS url
+
+
(** {1 Streaming Operations} *)
+
+
and upload t ?headers ?auth ?timeout ?method_ ?mime ?length ?on_progress ~source url =
+
let method_ = Option.value method_ ~default:Method.POST in
+
let body = Body.of_stream ?length (Option.value mime ~default:Mime.octet_stream) source in
+
(* TODO: Add progress tracking wrapper around source *)
+
execute_request t ?headers ~body ?auth ?timeout ~method_ url
+
+
and download t ?headers ?auth ?timeout ?on_progress url ~sink =
+
let response = execute_request t ?headers ?auth ?timeout ~method_:Method.GET url in
+
let body = Response.body response in
+
(* TODO: Add progress tracking wrapper *)
+
(* Copy from Buf_read to sink *)
+
let data = Buf_read.take_all body in
+
Flow.write sink [ Cstruct.of_string data ]
+
+
and download_file t ?headers ?auth ?timeout ?on_progress url path =
+
path |> Path.with_open_out (fun sink ->
+
download t ?headers ?auth ?timeout ?on_progress url ~sink
+
)
+
+
(** {1 Batch Operations} *)
+
+
and concurrent_requests t ?(max_concurrent = 10) tasks =
+
let sem = Semaphore.make max_concurrent in
+
+
tasks |> Fiber.List.map ~max_fibers:max_concurrent (fun task ->
+
Semaphore.acquire sem;
+
try
+
let result = task t in
+
Semaphore.release sem;
+
result
+
with exn ->
+
Semaphore.release sem;
+
raise exn
+
)
+
+
and map_concurrent t ?max_concurrent ~f items =
+
let tasks = List.map (fun item session -> f session item) items in
+
concurrent_requests t ?max_concurrent tasks
+
+
(** {1 Session Utilities} *)
+
+
and close t =
+
Log.info (fun m -> m "Closing session after %d requests" t.requests_made);
+
if t.persist_cookies && Option.is_some t.xdg then
+
save_cookies t
+
+
(** {1 Prepared Requests} *)
+
+
module Prepared = struct
+
type session = t
+
type t = {
+
session : session;
+
headers : Headers.t;
+
body : Body.t option;
+
auth : Auth.t option;
+
timeout : Timeout.t;
+
method_ : Method.t;
+
url : string;
+
}
+
+
let create ~session ?headers ?body ?auth ?timeout ~method_ url =
+
let headers =
+
session.default_headers
+
|> Headers.merge (Option.value headers ~default:Headers.empty)
+
|> Cookie_jar.add_to_headers session.cookie_jar ~url
+
in
+
let auth = Option.first auth session.auth in
+
let timeout = Option.value timeout ~default:session.timeout in
+
{ session; headers; body; auth; timeout; method_; url }
+
+
let headers t = t.headers
+
let set_header t key value = { t with headers = Headers.set key value t.headers }
+
let body t = t.body
+
let set_body t body = { t with body = Some body }
+
let url t = t.url
+
let method_ t = t.method_
+
+
let send t =
+
execute_request t.session ~headers:t.headers ?body:t.body ?auth:t.auth
+
~timeout:t.timeout ~method_:t.method_ t.url
+
+
let pp ppf t =
+
Format.fprintf ppf "@[<v>Prepared Request:@,\
+
method: %s@,\
+
url: %s@,\
+
@[%a@]@,\
+
auth: %s@,\
+
body: %s@]"
+
(Method.to_string t.method_)
+
t.url
+
Headers.pp t.headers
+
(if Option.is_some t.auth then "present" else "none")
+
(if Option.is_some t.body then "present" else "none")
+
end
+
+
let pp ppf t =
+
Mutex.lock t.mutex;
+
let stats = t.requests_made, t.total_time,
+
(match t.cookie_jar with jar -> 0) in (* TODO: Get actual count *)
+
Mutex.unlock t.mutex;
+
let requests, time, cookies = stats in
+
Format.fprintf ppf "@[<v>Session:@,\
+
requests made: %d@,\
+
total time: %.3fs@,\
+
cookies: %d@,\
+
auth: %s@,\
+
follow redirects: %b@,\
+
max redirects: %d@,\
+
retry: %s@,\
+
persist cookies: %b@,\
+
XDG: %s@]"
+
requests time cookies
+
(if Option.is_some t.auth then "configured" else "none")
+
t.follow_redirects
+
t.max_redirects
+
(if Option.is_some t.retry then "enabled" else "disabled")
+
t.persist_cookies
+
(match t.xdg with
+
| Some x -> Xdge.app_name x
+
| None -> "none")
+
+
let stats t =
+
Mutex.lock t.mutex;
+
let result = object
+
method requests_made = t.requests_made
+
method total_time = t.total_time
+
method cookies_count = 0 (* TODO: Get from cookie jar *)
+
method retries_count = t.retries_count
+
end in
+
Mutex.unlock t.mutex;
+
result
+
+
(** {1 Cmdliner Integration} *)
+
+
module Cmd = struct
+
open Cmdliner
+
+
type config = {
+
xdg : Xdge.t * Xdge.Cmd.t;
+
persist_cookies : bool;
+
verify_tls : bool;
+
timeout : float option;
+
max_retries : int;
+
retry_backoff : float;
+
follow_redirects : bool;
+
max_redirects : int;
+
user_agent : string option;
+
}
+
+
let default_config app_name xdg = {
+
xdg = (xdg, Xdge.Cmd.term app_name (Xdge.data_dir xdg) ());
+
persist_cookies = false;
+
verify_tls = true;
+
timeout = None;
+
max_retries = 3;
+
retry_backoff = 0.3;
+
follow_redirects = true;
+
max_redirects = 10;
+
user_agent = None;
+
}
+
+
let create config env sw =
+
let xdg, _xdg_cmd = config.xdg in
+
let retry = if config.max_retries > 0 then
+
Some (Retry.create_config
+
~max_retries:config.max_retries
+
~backoff_factor:config.retry_backoff ())
+
else None in
+
+
let timeout = match config.timeout with
+
| Some t -> Timeout.create ~total:t ()
+
| None -> Timeout.default in
+
+
let session = create ~sw
+
~xdg
+
~persist_cookies:config.persist_cookies
+
~verify_tls:config.verify_tls
+
~timeout
+
?retry
+
~follow_redirects:config.follow_redirects
+
~max_redirects:config.max_redirects
+
env in
+
+
(* Set user agent if provided *)
+
Option.iter (set_default_header session "User-Agent") config.user_agent;
+
+
session
+
+
(* Individual terms *)
+
+
let persist_cookies_term =
+
let doc = "Persist cookies to disk between sessions" in
+
let env = Cmd.Env.info "REQUESTS_PERSIST_COOKIES" in
+
Arg.(value & flag & info ["persist-cookies"] ~env ~doc)
+
+
let verify_tls_term =
+
let doc = "Skip TLS certificate verification (insecure)" in
+
let env = Cmd.Env.info "REQUESTS_NO_VERIFY_TLS" in
+
Term.(const (fun no_verify -> not no_verify) $
+
Arg.(value & flag & info ["no-verify-tls"] ~env ~doc))
+
+
let timeout_term =
+
let doc = "Request timeout in seconds" in
+
let env = Cmd.Env.info "REQUESTS_TIMEOUT" in
+
Arg.(value & opt (some float) None & info ["timeout"] ~env ~docv:"SECONDS" ~doc)
+
+
let retries_term =
+
let doc = "Maximum number of request retries" in
+
let env = Cmd.Env.info "REQUESTS_MAX_RETRIES" in
+
Arg.(value & opt int 3 & info ["max-retries"] ~env ~docv:"N" ~doc)
+
+
let retry_backoff_term =
+
let doc = "Retry backoff factor for exponential delay" in
+
let env = Cmd.Env.info "REQUESTS_RETRY_BACKOFF" in
+
Arg.(value & opt float 0.3 & info ["retry-backoff"] ~env ~docv:"FACTOR" ~doc)
+
+
let follow_redirects_term =
+
let doc = "Don't follow HTTP redirects" in
+
let env = Cmd.Env.info "REQUESTS_NO_FOLLOW_REDIRECTS" in
+
Term.(const (fun no_follow -> not no_follow) $
+
Arg.(value & flag & info ["no-follow-redirects"] ~env ~doc))
+
+
let max_redirects_term =
+
let doc = "Maximum number of redirects to follow" in
+
let env = Cmd.Env.info "REQUESTS_MAX_REDIRECTS" in
+
Arg.(value & opt int 10 & info ["max-redirects"] ~env ~docv:"N" ~doc)
+
+
let user_agent_term =
+
let doc = "User-Agent header to send with requests" in
+
let env = Cmd.Env.info "REQUESTS_USER_AGENT" in
+
Arg.(value & opt (some string) None & info ["user-agent"] ~env ~docv:"STRING" ~doc)
+
+
(* Combined terms *)
+
+
let config_term app_name fs =
+
let xdg_term = Xdge.Cmd.term app_name fs
+
~config:true ~data:true ~cache:true ~state:false ~runtime:false () in
+
Term.(const (fun xdg persist verify timeout retries backoff follow max_redir ua ->
+
{ xdg; persist_cookies = persist; verify_tls = verify;
+
timeout; max_retries = retries; retry_backoff = backoff;
+
follow_redirects = follow; max_redirects = max_redir;
+
user_agent = ua })
+
$ xdg_term
+
$ persist_cookies_term
+
$ verify_tls_term
+
$ timeout_term
+
$ retries_term
+
$ retry_backoff_term
+
$ follow_redirects_term
+
$ max_redirects_term
+
$ user_agent_term)
+
+
let session_term app_name env sw =
+
let config_t = config_term app_name env#fs in
+
Term.(const (fun config -> create config env sw) $ config_t)
+
+
let minimal_term app_name fs =
+
let xdg_term = Xdge.Cmd.term app_name fs
+
~config:false ~data:true ~cache:true ~state:false ~runtime:false () in
+
Term.(const (fun (xdg, _xdg_cmd) persist -> (xdg, persist))
+
$ xdg_term
+
$ persist_cookies_term)
+
+
let env_docs app_name =
+
let app_upper = String.uppercase_ascii app_name in
+
Printf.sprintf
+
"## ENVIRONMENT\n\n\
+
The following environment variables affect %s:\n\n\
+
**%s_CONFIG_DIR**\n\
+
: Override configuration directory location\n\n\
+
**%s_DATA_DIR**\n\
+
: Override data directory location (for cookies)\n\n\
+
**%s_CACHE_DIR**\n\
+
: Override cache directory location\n\n\
+
**XDG_CONFIG_HOME**\n\
+
: Base directory for user configuration files (default: ~/.config)\n\n\
+
**XDG_DATA_HOME**\n\
+
: Base directory for user data files (default: ~/.local/share)\n\n\
+
**XDG_CACHE_HOME**\n\
+
: Base directory for user cache files (default: ~/.cache)\n\n\
+
**REQUESTS_PERSIST_COOKIES**\n\
+
: Set to '1' to persist cookies by default\n\n\
+
**REQUESTS_NO_VERIFY_TLS**\n\
+
: Set to '1' to disable TLS verification (insecure)\n\n\
+
**REQUESTS_TIMEOUT**\n\
+
: Default request timeout in seconds\n\n\
+
**REQUESTS_MAX_RETRIES**\n\
+
: Maximum number of retries for failed requests\n\n\
+
**REQUESTS_USER_AGENT**\n\
+
: Default User-Agent header\n\n\
+
**HTTP_PROXY**, **HTTPS_PROXY**, **NO_PROXY**\n\
+
: Proxy configuration (when proxy support is implemented)"
+
app_name app_upper app_upper app_upper
+
+
let pp_config ppf config =
+
let xdg, xdg_cmd = config.xdg in
+
Format.fprintf ppf "@[<v>Session Configuration:@,\
+
@[<v 2>XDG Directories:@,%a@]@,\
+
persist cookies: %b@,\
+
verify TLS: %b@,\
+
timeout: %s@,\
+
max retries: %d@,\
+
retry backoff: %.2f@,\
+
follow redirects: %b@,\
+
max redirects: %d@,\
+
user agent: %s@]"
+
Xdge.Cmd.pp xdg_cmd
+
config.persist_cookies
+
config.verify_tls
+
(match config.timeout with None -> "none" | Some t -> Printf.sprintf "%.1fs" t)
+
config.max_retries
+
config.retry_backoff
+
config.follow_redirects
+
config.max_redirects
+
(Option.value config.user_agent ~default:"default")
+
end
+544
stack/requests/lib/session.mli
···
+
(** HTTP Session with persistent state across requests
+
+
Sessions provide stateful HTTP clients that maintain cookies, default headers,
+
authentication, and other configuration across multiple requests. They follow
+
the Eio concurrency model and are thread-safe.
+
+
Example usage:
+
{[
+
Eio_main.run @@ fun env ->
+
Switch.run @@ fun sw ->
+
+
(* Create a session *)
+
let session = Session.create ~sw env in
+
+
(* Cookies are automatically handled *)
+
let resp1 = Session.get session "https://httpbin.org/cookies/set?foo=bar" in
+
let resp2 = Session.get session "https://httpbin.org/cookies" in
+
(* resp2 will include the foo=bar cookie *)
+
+
(* Set default headers for all requests *)
+
Session.set_default_header session "User-Agent" "MyApp/1.0";
+
+
(* Set authentication for all requests *)
+
Session.set_auth session (Auth.bearer "token123");
+
]}
+
*)
+
+
open Eio
+
+
(** {1 Types} *)
+
+
type ('clock, 'net) t
+
(** A session maintains state across multiple HTTP requests *)
+
+
(** {1 Session Creation and Configuration} *)
+
+
val create :
+
sw:Switch.t ->
+
?client:('clock, 'net) Client.t ->
+
?cookie_jar:Cookie_jar.t ->
+
?default_headers:Headers.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?follow_redirects:bool ->
+
?max_redirects:int ->
+
?verify_tls:bool ->
+
?retry:Retry.config ->
+
?persist_cookies:bool ->
+
?xdg:Xdge.t ->
+
< clock: 'clock; net: 'net; fs: _ Path.t; .. > ->
+
('clock, 'net) t
+
(** Create a new session.
+
+
@param sw Switch for resource management
+
@param client Base client configuration (creates default if not provided)
+
@param cookie_jar Use existing cookie jar (creates new one if not provided)
+
@param default_headers Headers to include in all requests
+
@param auth Default authentication for all requests
+
@param timeout Default timeout for all requests
+
@param follow_redirects Whether to follow redirects (default: true)
+
@param max_redirects Maximum number of redirects (default: 10)
+
@param verify_tls Whether to verify TLS certificates (default: true)
+
@param retry Retry configuration for failed requests
+
@param persist_cookies Whether to save/load cookies from disk (default: false)
+
@param xdg XDG directory configuration (creates default "requests" if not provided)
+
*)
+
+
val with_session :
+
sw:Switch.t ->
+
?client:('clock, 'net) Client.t ->
+
?cookie_jar:Cookie_jar.t ->
+
?default_headers:Headers.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?follow_redirects:bool ->
+
?max_redirects:int ->
+
?verify_tls:bool ->
+
?retry:Retry.config ->
+
?persist_cookies:bool ->
+
?xdg:Xdge.t ->
+
< clock: 'clock; net: 'net; fs: _ Path.t; .. > ->
+
(('clock, 'net) t -> 'a) ->
+
'a
+
(** Create a session and run a function with it, ensuring cleanup.
+
The session is automatically closed when the function returns. *)
+
+
(** {1 Configuration Management} *)
+
+
val set_default_header : ('clock, 'net) t -> string -> string -> unit
+
(** Set a default header that will be included in all requests *)
+
+
val remove_default_header : ('clock, 'net) t -> string -> unit
+
(** Remove a default header *)
+
+
val set_auth : ('clock, 'net) t -> Auth.t -> unit
+
(** Set default authentication for all requests *)
+
+
val clear_auth : ('clock, 'net) t -> unit
+
(** Clear default authentication *)
+
+
val set_timeout : ('clock, 'net) t -> Timeout.t -> unit
+
(** Set default timeout for all requests *)
+
+
val set_retry : ('clock, 'net) t -> Retry.config -> unit
+
(** Set retry configuration *)
+
+
val disable_retry : ('clock, 'net) t -> unit
+
(** Disable automatic retry *)
+
+
(** {1 Cookie Management} *)
+
+
val cookies : ('clock, 'net) t -> Cookie_jar.t
+
(** Get the session's cookie jar for direct manipulation *)
+
+
val clear_cookies : ('clock, 'net) t -> unit
+
(** Clear all cookies *)
+
+
val save_cookies : ('clock, 'net) t -> unit
+
(** Manually save cookies to disk (if persist_cookies was enabled) *)
+
+
val load_cookies : ('clock, 'net) t -> unit
+
(** Manually reload cookies from disk (if persist_cookies was enabled) *)
+
+
(** {1 Request Methods} *)
+
+
(** All request methods automatically:
+
- Include session's default headers
+
- Use session's authentication
+
- Handle cookies (extract from responses, add to requests)
+
- Apply retry logic if configured
+
- Follow redirects based on session configuration
+
*)
+
+
val request :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?body:Body.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?follow_redirects:bool ->
+
?max_redirects:int ->
+
method_:Method.t ->
+
string ->
+
Response.t
+
(** Make a request with the session.
+
Optional parameters override session defaults. *)
+
+
val get :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?params:(string * string) list ->
+
string ->
+
Response.t
+
(** GET request with optional query parameters *)
+
+
val post :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?body:Body.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?json:Yojson.Safe.t ->
+
?form:(string * string) list ->
+
string ->
+
Response.t
+
(** POST request with optional JSON or form data *)
+
+
val put :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?body:Body.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?json:Yojson.Safe.t ->
+
string ->
+
Response.t
+
(** PUT request with optional JSON data *)
+
+
val patch :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?body:Body.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?json:Yojson.Safe.t ->
+
string ->
+
Response.t
+
(** PATCH request with optional JSON data *)
+
+
val delete :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
string ->
+
Response.t
+
(** DELETE request *)
+
+
val head :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
string ->
+
Response.t
+
(** HEAD request *)
+
+
val options :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
string ->
+
Response.t
+
(** OPTIONS request *)
+
+
(** {1 Streaming Operations} *)
+
+
val upload :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?method_:Method.t ->
+
?mime:Mime.t ->
+
?length:int64 ->
+
?on_progress:(sent:int64 -> total:int64 option -> unit) ->
+
source:Flow.source_ty Resource.t ->
+
string ->
+
Response.t
+
(** Upload from a stream with optional progress callback *)
+
+
val download :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?on_progress:(received:int64 -> total:int64 option -> unit) ->
+
string ->
+
sink:Flow.sink_ty Resource.t ->
+
unit
+
(** Download to a stream with optional progress callback *)
+
+
val download_file :
+
('clock, 'net) t ->
+
?headers:Headers.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
?on_progress:(received:int64 -> total:int64 option -> unit) ->
+
string ->
+
_ Path.t ->
+
unit
+
(** Download directly to a file *)
+
+
(** {1 Batch Operations} *)
+
+
val concurrent_requests :
+
('clock, 'net) t ->
+
?max_concurrent:int ->
+
(('clock, 'net) t -> 'a) list ->
+
'a list
+
(** Execute multiple requests concurrently with the same session.
+
Cookies and state are shared safely across all requests.
+
@param max_concurrent Maximum number of concurrent requests (default: 10) *)
+
+
val map_concurrent :
+
('clock, 'net) t ->
+
?max_concurrent:int ->
+
f:(('clock, 'net) t -> 'a -> 'b) ->
+
'a list ->
+
'b list
+
(** Map a function over a list concurrently using the session *)
+
+
(** {1 Prepared Requests} *)
+
+
module Prepared : sig
+
type prepared
+
(** A prepared request with all parameters resolved *)
+
+
val create :
+
'clock 'net.
+
session:('clock, 'net) t ->
+
?headers:Headers.t ->
+
?body:Body.t ->
+
?auth:Auth.t ->
+
?timeout:Timeout.t ->
+
method_:Method.t ->
+
string ->
+
prepared
+
(** Prepare a request without sending it *)
+
+
val headers : prepared -> Headers.t
+
(** Get the prepared headers (includes cookies and auth) *)
+
+
val set_header : prepared -> string -> string -> prepared
+
(** Modify a header in the prepared request *)
+
+
val body : prepared -> Body.t option
+
(** Get the prepared body *)
+
+
val set_body : prepared -> Body.t -> prepared
+
(** Change the body *)
+
+
val url : prepared -> string
+
(** Get the URL *)
+
+
val method_ : prepared -> Method.t
+
(** Get the HTTP method *)
+
+
val send : prepared -> Response.t
+
(** Send the prepared request *)
+
+
val pp : Format.formatter -> prepared -> unit
+
(** Pretty print for debugging *)
+
end
+
+
(** {1 Hooks and Middleware} *)
+
+
(* TODO: Add hooks support in a more functional style later *)
+
+
(** {1 Session Utilities} *)
+
+
val close : ('clock, 'net) t -> unit
+
(** Close the session and clean up resources.
+
Saves cookies if persistence is enabled. *)
+
+
val pp : Format.formatter -> ('clock, 'net) t -> unit
+
(** Pretty print session configuration *)
+
+
val stats : ('clock, 'net) t -> <
+
requests_made : int;
+
total_time : float;
+
cookies_count : int;
+
retries_count : int;
+
>
+
(** Get session statistics *)
+
+
(** {1 Examples} *)
+
+
(** {2 Basic Usage}
+
{[
+
let session = Session.create ~sw env in
+
let response = Session.get session "https://api.github.com/user" in
+
Printf.printf "Status: %d\n" (Response.status response)
+
]}
+
*)
+
+
(** {2 With Authentication}
+
{[
+
let session = Session.create ~sw env in
+
Session.set_auth session (Auth.bearer "github_token");
+
Session.set_default_header session "Accept" "application/vnd.github.v3+json";
+
+
let user = Session.get session "https://api.github.com/user" in
+
let repos = Session.get session "https://api.github.com/user/repos" in
+
(* Both requests will use the same auth token and headers *)
+
]}
+
*)
+
+
(** {2 Form Login with Cookies}
+
{[
+
let session = Session.create ~sw ~persist_cookies:true env in
+
+
(* Login - cookies will be saved *)
+
let login = Session.post session
+
~form:["username", "user"; "password", "pass"]
+
"https://example.com/login" in
+
+
(* Access protected resource - cookies are automatically included *)
+
let dashboard = Session.get session "https://example.com/dashboard" in
+
]}
+
*)
+
+
(** {2 Concurrent Downloads}
+
{[
+
let download_all session urls =
+
Session.map_concurrent session ~max_concurrent:5
+
~f:(fun sess url ->
+
let resp = Session.get sess url in
+
Response.body resp |> Buf_read.take_all)
+
urls
+
]}
+
*)
+
+
(** {1 Cmdliner Integration} *)
+
+
module Cmd : sig
+
(** Cmdliner integration for Requests session configuration.
+
+
This module provides command-line argument handling for configuring
+
HTTP sessions, including XDG directory paths, timeouts, retries,
+
and other session parameters. *)
+
+
(** Session configuration from command line and environment *)
+
type config = {
+
xdg : Xdge.t * Xdge.Cmd.t; (** XDG paths and their sources *)
+
persist_cookies : bool; (** Whether to persist cookies *)
+
verify_tls : bool; (** Whether to verify TLS certificates *)
+
timeout : float option; (** Request timeout in seconds *)
+
max_retries : int; (** Maximum number of retries *)
+
retry_backoff : float; (** Retry backoff factor *)
+
follow_redirects : bool; (** Whether to follow redirects *)
+
max_redirects : int; (** Maximum number of redirects *)
+
user_agent : string option; (** User-Agent header *)
+
}
+
+
val default_config : string -> Xdge.t -> config
+
(** [default_config app_name xdg] creates a default configuration *)
+
+
val create : config -> < clock: 'clock; net: 'net; fs: _ Path.t; .. > -> Switch.t -> ('clock, 'net) t
+
(** [create config env sw] creates a session from command-line configuration *)
+
+
(** {2 Individual Terms} *)
+
+
val persist_cookies_term : bool Cmdliner.Term.t
+
(** Term for [--persist-cookies] flag *)
+
+
val verify_tls_term : bool Cmdliner.Term.t
+
(** Term for [--no-verify-tls] flag *)
+
+
val timeout_term : float option Cmdliner.Term.t
+
(** Term for [--timeout SECONDS] option *)
+
+
val retries_term : int Cmdliner.Term.t
+
(** Term for [--max-retries N] option *)
+
+
val retry_backoff_term : float Cmdliner.Term.t
+
(** Term for [--retry-backoff FACTOR] option *)
+
+
val follow_redirects_term : bool Cmdliner.Term.t
+
(** Term for [--no-follow-redirects] flag *)
+
+
val max_redirects_term : int Cmdliner.Term.t
+
(** Term for [--max-redirects N] option *)
+
+
val user_agent_term : string option Cmdliner.Term.t
+
(** Term for [--user-agent STRING] option *)
+
+
(** {2 Combined Terms} *)
+
+
val config_term : string -> Eio.Fs.dir_ty Eio.Path.t -> config Cmdliner.Term.t
+
(** [config_term app_name fs] creates a complete configuration term.
+
+
This combines all individual terms plus XDG configuration into
+
a single term that can be used to configure a session.
+
+
{b Generated Flags:}
+
- [--config-dir DIR]: Configuration directory
+
- [--data-dir DIR]: Data directory
+
- [--cache-dir DIR]: Cache directory
+
- [--state-dir DIR]: State directory
+
- [--persist-cookies]: Enable cookie persistence
+
- [--no-verify-tls]: Disable TLS verification
+
- [--timeout SECONDS]: Request timeout
+
- [--max-retries N]: Maximum retries
+
- [--retry-backoff FACTOR]: Retry backoff multiplier
+
- [--no-follow-redirects]: Disable redirect following
+
- [--max-redirects N]: Maximum redirects to follow
+
- [--user-agent STRING]: User-Agent header
+
+
{b Example:}
+
{[
+
let open Cmdliner in
+
let config_t = Session.Cmd.config_term "myapp" env#fs in
+
let main config =
+
Switch.run @@ fun sw ->
+
let session = Session.Cmd.create config env sw in
+
(* Use session *)
+
in
+
let cmd = Cmd.v info Term.(const main $ config_t) in
+
Cmd.eval cmd
+
]} *)
+
+
val session_term : string -> < clock: 'clock; net: 'net; fs: _ Path.t; .. > -> Switch.t -> ('clock, 'net) t Cmdliner.Term.t
+
(** [session_term app_name env sw] creates a term that directly produces a session.
+
+
This is a convenience function that combines configuration parsing
+
with session creation.
+
+
{b Example:}
+
{[
+
let open Cmdliner in
+
let main session =
+
(* Use session directly *)
+
let resp = Session.get session "https://example.com" in
+
(* ... *)
+
in
+
Switch.run @@ fun sw ->
+
let session_t = Session.Cmd.session_term "myapp" env sw in
+
let cmd = Cmd.v info Term.(const main $ session_t) in
+
Cmd.eval cmd
+
]} *)
+
+
val minimal_term : string -> Eio.Fs.dir_ty Eio.Path.t -> (Xdge.t * bool) Cmdliner.Term.t
+
(** [minimal_term app_name fs] creates a minimal configuration term.
+
+
This only provides:
+
- [--cache-dir DIR]: Cache directory for responses
+
- [--persist-cookies]: Cookie persistence flag
+
+
Returns the XDG context and persist_cookies boolean.
+
+
{b Example:}
+
{[
+
let open Cmdliner in
+
let minimal_t = Session.Cmd.minimal_term "myapp" env#fs in
+
let main (xdg, persist) =
+
Switch.run @@ fun sw ->
+
let session = Session.create ~sw ~xdg ~persist_cookies:persist env in
+
(* Use session *)
+
in
+
let cmd = Cmd.v info Term.(const main $ minimal_t) in
+
Cmd.eval cmd
+
]} *)
+
+
(** {2 Documentation} *)
+
+
val env_docs : string -> string
+
(** [env_docs app_name] generates environment variable documentation.
+
+
Returns formatted documentation for all environment variables that
+
affect session configuration, including XDG variables.
+
+
{b Included Variables:}
+
- [${APP_NAME}_CONFIG_DIR]: Configuration directory
+
- [${APP_NAME}_DATA_DIR]: Data directory
+
- [${APP_NAME}_CACHE_DIR]: Cache directory
+
- [${APP_NAME}_STATE_DIR]: State directory
+
- [XDG_CONFIG_HOME], [XDG_DATA_HOME], [XDG_CACHE_HOME], [XDG_STATE_HOME]
+
- [HTTP_PROXY], [HTTPS_PROXY], [NO_PROXY] (when proxy support is added)
+
+
{b Example:}
+
{[
+
let env_info = Cmdliner.Cmd.Env.info
+
~docs:Cmdliner.Manpage.s_environment
+
~doc:(Session.Cmd.env_docs "myapp")
+
()
+
]} *)
+
+
val pp_config : Format.formatter -> config -> unit
+
(** Pretty print session configuration for debugging *)
+
end
+24 -5
stack/requests/lib/stream.ml
···
+
let src = Logs.Src.create "requests.stream" ~doc:"HTTP Request Stream"
+
module Log = (val Logs.src_log src : Logs.LOG)
+
exception Timeout
exception TooManyRedirects of { url: string; count: int }
exception ConnectionError of string
···
let client = get_client client in
let start_time = Unix.gettimeofday () in
+
Log.info (fun m -> m "Making %s request to %s" (Method.to_string method_) url);
+
(* Note: pool_config is accepted for API compatibility but not used yet
as Cohttp_eio doesn't expose connection pooling configuration.
This will be implemented when connection pooling is added. *)
···
(* Apply auth *)
let headers = match auth with
-
| Some a -> Auth.apply a headers
+
| Some a ->
+
Log.debug (fun m -> m "Applying authentication");
+
Auth.apply a headers
| None -> headers
in
···
(* Create HTTPS handler if TLS is configured *)
let https = match Client.tls_config client with
-
| None -> None
+
| None ->
+
Log.debug (fun m -> m "No TLS configuration");
+
None
| Some tls_config ->
+
Log.debug (fun m -> m "Using TLS configuration");
let https_fn uri socket =
let host =
Uri.host uri
···
let timeout_seconds = Timeout.total t in
(match timeout_seconds with
| Some seconds ->
+
Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds);
Eio.Time.with_timeout_exn (Client.clock client) seconds make_request
| None -> make_request ())
| None -> make_request ()
···
let cohttp_resp_headers = Cohttp.Response.headers resp in
let resp_headers = headers_from_cohttp cohttp_resp_headers in
+
Log.info (fun m -> m "Received response: status=%d" status);
+
(* Handle redirects if enabled *)
let follow_redirects = Option.value follow_redirects ~default:true in
let max_redirects = Option.value max_redirects ~default:10 in
···
let final_resp, final_body, final_url =
if follow_redirects && (status >= 300 && status < 400) then
let rec follow_redirect url redirects_left =
-
if redirects_left <= 0 then
+
if redirects_left <= 0 then begin
+
Log.err (fun m -> m "Too many redirects (%d) for %s" max_redirects url);
raise (TooManyRedirects { url; count = max_redirects })
-
else
+
end else
(* Get location header from Cohttp headers *)
match Cohttp.Header.get cohttp_resp_headers "location" with
-
| None -> (resp, resp_body, url)
+
| None ->
+
Log.debug (fun m -> m "Redirect response missing Location header");
+
(resp, resp_body, url)
| Some location ->
+
Log.info (fun m -> m "Following redirect to %s (%d remaining)"
+
location redirects_left);
(* Make new request to redirect location *)
let new_uri = Uri.of_string location in
let new_resp, new_body =
···
let buf_read = Eio.Buf_read.of_flow ~max_size:(16 * 1024 * 1024) final_body in
let elapsed = Unix.gettimeofday () -. start_time in
+
Log.info (fun m -> m "Request completed in %.3f seconds" elapsed);
Response.make
~status