A batteries included HTTP/1.1 client in OCaml
at main 225 lines 8.8 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6let src = Logs.Src.create "requests.retry" ~doc:"HTTP Request Retry Logic" 7module Log = (val Logs.src_log src : Logs.LOG) 8 9(** Custom retry predicate for responses. 10 Per Recommendation #14: Allow user-defined retry logic. 11 The predicate receives (method, status, headers) and returns true to retry. 12 This runs in addition to the built-in status_forcelist check. *) 13type response_predicate = Method.t -> int -> Headers.t -> bool 14 15(** Custom retry predicate for exceptions. 16 Returns true if the exception should trigger a retry. *) 17type exception_predicate = exn -> bool 18 19type config = { 20 max_retries : int; 21 backoff_factor : float; 22 backoff_max : float; 23 status_forcelist : int list; 24 allowed_methods : Method.t list; 25 respect_retry_after : bool; 26 jitter : bool; 27 retry_response : response_predicate option; (** Per Recommendation #14 *) 28 retry_exception : exception_predicate option; (** Per Recommendation #14 *) 29 strict_method_semantics : bool; 30 (** When true, raise an error if asked to retry a non-idempotent method. 31 Per RFC 9110 Section 9.2.2: Non-idempotent methods should not be retried. 32 Default is false (just log a debug message). *) 33} 34 35let default_config = { 36 max_retries = 3; 37 backoff_factor = 0.3; 38 backoff_max = 120.0; 39 status_forcelist = [408; 429; 500; 502; 503; 504]; 40 allowed_methods = [`GET; `HEAD; `PUT; `DELETE; `OPTIONS; `TRACE]; 41 respect_retry_after = true; 42 jitter = true; 43 retry_response = None; 44 retry_exception = None; 45 strict_method_semantics = false; 46} 47 48let create_config 49 ?(max_retries = 3) 50 ?(backoff_factor = 0.3) 51 ?(backoff_max = 120.0) 52 ?(status_forcelist = [408; 429; 500; 502; 503; 504]) 53 ?(allowed_methods = [`GET; `HEAD; `PUT; `DELETE; `OPTIONS; `TRACE]) 54 ?(respect_retry_after = true) 55 ?(jitter = true) 56 ?retry_response 57 ?retry_exception 58 ?(strict_method_semantics = false) 59 () = 60 Log.debug (fun m -> m "Creating retry config: max_retries=%d backoff_factor=%.2f \ 61 strict_method_semantics=%b custom_predicates=%b" 62 max_retries backoff_factor strict_method_semantics 63 (Option.is_some retry_response || Option.is_some retry_exception)); 64 { 65 max_retries; 66 backoff_factor; 67 backoff_max; 68 status_forcelist; 69 allowed_methods; 70 respect_retry_after; 71 jitter; 72 retry_response; 73 retry_exception; 74 strict_method_semantics; 75 } 76 77(** Check if a response should be retried based on built-in rules only. 78 Use [should_retry_response] for full custom predicate support. 79 @raise Error.t if strict_method_semantics is enabled and method is not idempotent *) 80let should_retry ~config ~method_ ~status = 81 let method_allowed = List.mem method_ config.allowed_methods in 82 let status_retryable = List.mem status config.status_forcelist in 83 let should = method_allowed && status_retryable in 84 (* Per RFC 9110 Section 9.2.2: Only idempotent methods should be retried automatically *) 85 if status_retryable && not method_allowed then begin 86 if config.strict_method_semantics then 87 raise (Error.invalid_requestf 88 "Cannot retry %s request: method is not idempotent \ 89 (RFC 9110 Section 9.2.2). Disable strict_method_semantics to allow." 90 (Method.to_string method_)) 91 else 92 Log.debug (fun m -> m "Not retrying %s request (status %d): method is not idempotent \ 93 (RFC 9110 Section 9.2.2)" (Method.to_string method_) status) 94 end else 95 Log.debug (fun m -> m "Should retry? method=%s status=%d -> %b" 96 (Method.to_string method_) status should); 97 should 98 99(** Check if a response should be retried, including custom predicates. 100 Per Recommendation #14: User-defined retry logic. 101 Returns true if either built-in rules or custom predicate says to retry. *) 102let should_retry_response ~config ~method_ ~status ~headers = 103 (* Check built-in rules first *) 104 let builtin_should_retry = 105 List.mem method_ config.allowed_methods && 106 List.mem status config.status_forcelist 107 in 108 (* Check custom predicate if provided *) 109 let custom_should_retry = match config.retry_response with 110 | Some predicate -> predicate method_ status headers 111 | None -> false 112 in 113 let should = builtin_should_retry || custom_should_retry in 114 Log.debug (fun m -> m "Should retry response? method=%s status=%d builtin=%b custom=%b -> %b" 115 (Method.to_string method_) status builtin_should_retry custom_should_retry should); 116 should 117 118(** Check if an exception should trigger a retry, including custom predicates. 119 Per Recommendation #14: User-defined retry logic. *) 120let should_retry_exn ~config exn = 121 match config.retry_exception with 122 | Some predicate -> predicate exn 123 | None -> false 124 125let calculate_backoff ~config ~attempt = 126 let base_delay = config.backoff_factor *. (2.0 ** float_of_int attempt) in 127 let delay = 128 if config.jitter then 129 (* Add random jitter between 0 and base_delay *) 130 base_delay +. Random.float base_delay 131 else 132 base_delay 133 in 134 let final_delay = min delay config.backoff_max in 135 Log.debug (fun m -> m "Backoff calculation: attempt=%d base=%.2f jitter=%b -> %.2f seconds" 136 attempt base_delay config.jitter final_delay); 137 final_delay 138 139(** Parse Retry-After header and cap to backoff_max to prevent DoS. 140 Per RFC 9110 Section 10.2.3 and Recommendation #5: 141 Cap server-specified Retry-After values to prevent malicious servers 142 from causing indefinite client blocking. *) 143let parse_retry_after ?(backoff_max = 120.0) value = 144 Log.debug (fun m -> m "Parsing Retry-After header: %s" value); 145 146 let raw_delay = 147 (* First try to parse as integer (delay in seconds) *) 148 match int_of_string_opt value with 149 | Some seconds -> 150 Log.debug (fun m -> m "Retry-After is %d seconds" seconds); 151 Some (float_of_int seconds) 152 | None -> 153 (* Try to parse as HTTP-date (IMF-fixdate per RFC 9110 Section 5.6.7) *) 154 match Http_date.parse value with 155 | Some time -> 156 let now = Unix.time () in 157 let target = Ptime.to_float_s time in 158 let delay = max 0.0 (target -. now) in 159 Log.debug (fun m -> m "Retry-After is HTTP date, delay=%.2f seconds" delay); 160 Some delay 161 | None -> 162 Log.warn (fun m -> m "Failed to parse Retry-After header: %s" value); 163 None 164 in 165 (* Cap to backoff_max to prevent DoS from malicious Retry-After values *) 166 match raw_delay with 167 | Some delay when delay > backoff_max -> 168 Log.warn (fun m -> m "Retry-After delay %.2fs exceeds backoff_max %.2fs, capping" 169 delay backoff_max); 170 Some backoff_max 171 | other -> other 172 173let with_retry ~sw:_ ~clock ~config ~f ~should_retry_exn = 174 let rec attempt_with_retry attempt = 175 Log.info (fun m -> m "Attempt %d/%d" attempt (config.max_retries + 1)); 176 177 match f () with 178 | result -> 179 if attempt > 1 then 180 Log.info (fun m -> m "Request succeeded after %d attempts" attempt); 181 result 182 | exception exn when attempt <= config.max_retries && should_retry_exn exn -> 183 let delay = calculate_backoff ~config ~attempt in 184 185 Log.warn (fun m -> m "Request failed (attempt %d/%d): %s. Retrying in %.2f seconds..." 186 attempt (config.max_retries + 1) (Printexc.to_string exn) delay); 187 188 (* Sleep for the backoff duration *) 189 Eio.Time.sleep clock delay; 190 191 attempt_with_retry (attempt + 1) 192 | exception exn -> 193 if attempt > config.max_retries then 194 Log.err (fun m -> m "Request failed after %d attempts: %s" 195 attempt (Printexc.to_string exn)) 196 else 197 Log.err (fun m -> m "Request failed and won't be retried: %s" 198 (Printexc.to_string exn)); 199 raise exn 200 in 201 attempt_with_retry 1 202 203let pp_config ppf config = 204 Format.fprintf ppf "@[<v>Retry Configuration:@,\ 205 @[<v 2>\ 206 max_retries: %d@,\ 207 backoff_factor: %.2f@,\ 208 backoff_max: %.1f seconds@,\ 209 status_forcelist: [%a]@,\ 210 allowed_methods: [%a]@,\ 211 respect_retry_after: %b@,\ 212 jitter: %b\ 213 @]@]" 214 config.max_retries 215 config.backoff_factor 216 config.backoff_max 217 Format.(pp_print_list ~pp_sep:(fun ppf () -> fprintf ppf ", ") pp_print_int) config.status_forcelist 218 Format.(pp_print_list ~pp_sep:(fun ppf () -> fprintf ppf ", ") 219 (fun ppf m -> pp_print_string ppf (Method.to_string m))) config.allowed_methods 220 config.respect_retry_after 221 config.jitter 222 223let log_retry ~attempt ~delay ~reason = 224 Log.info (fun m -> m "Retry attempt %d scheduled in %.2f seconds. Reason: %s" 225 attempt delay reason)