forked from
anil.recoil.org/ocaml-requests
A batteries included HTTP/1.1 client in OCaml
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6(** Centralized error handling for the Requests library using Eio.Io exceptions.
7
8 This module follows the Eio.Io exception pattern for structured error
9 handling, providing granular error types and query functions for smart retry
10 logic.
11
12 {2 Usage}
13
14 Errors are raised using the Eio.Io pattern:
15 {[
16 raise
17 (Error.err
18 (Error.Timeout { operation = "connect"; duration = Some 30.0 }))
19 ]}
20
21 To catch and handle errors:
22 {[
23 try
24 (* ... HTTP request ... *)
25 with
26 | Eio.Io (Error.E e, _) when Error.is_retryable e ->
27 (* Retry the request *)
28 | Eio.Io (Error.E e, _) ->
29 Printf.eprintf "Request failed: %s\n" (Error.to_string e)
30 ]} *)
31
32val src : Logs.Src.t
33(** Log source for error reporting. *)
34
35(** {1 Error Type}
36
37 Granular error variants with contextual information. Each variant contains a
38 record with relevant details. *)
39
40type t =
41 (* Timeout errors *)
42 | Timeout of { operation : string; duration : float option }
43 (* Redirect errors *)
44 | Too_many_redirects of { url : string; count : int; max : int }
45 | Invalid_redirect of { url : string; reason : string }
46 (* HTTP response errors *)
47 (* Note: headers stored as list to avoid dependency cycle with Headers module *)
48 | Http_error of {
49 url : string;
50 status : int;
51 reason : string;
52 body_preview : string option;
53 headers : (string * string) list;
54 }
55 (* Authentication errors *)
56 | Authentication_failed of { url : string; reason : string }
57 (* Connection errors - granular breakdown *)
58 | Dns_resolution_failed of { hostname : string }
59 | Tcp_connect_failed of { host : string; port : int; reason : string }
60 | Tls_handshake_failed of { host : string; reason : string }
61 (* Security-related errors *)
62 | Invalid_header of { name : string; reason : string }
63 | Body_too_large of { limit : int64; actual : int64 option }
64 | Headers_too_large of { limit : int; actual : int }
65 | Decompression_bomb of { limit : int64; ratio : float }
66 | Content_length_mismatch of { expected : int64; actual : int64 }
67 | Insecure_auth of { url : string; auth_type : string }
68 (** Per RFC 7617 Section 4 and RFC 6750 Section 5.1: Basic, Bearer, and
69 Digest authentication over unencrypted HTTP exposes credentials to
70 eavesdropping. Raised when attempting to use these auth methods over
71 HTTP without explicit opt-out. *)
72 (* JSON errors *)
73 | Json_parse_error of { body_preview : string; reason : string }
74 | Json_encode_error of { reason : string }
75 (* Other errors *)
76 | Proxy_error of { host : string; reason : string }
77 | Encoding_error of { encoding : string; reason : string }
78 | Invalid_url of { url : string; reason : string }
79 | Invalid_request of { reason : string }
80 (* OAuth 2.0 errors - per RFC 6749 Section 5.2 *)
81 | Oauth_error of {
82 error_code : string;
83 description : string option;
84 uri : string option;
85 }
86 (** OAuth 2.0 error response from authorization server. Per
87 {{:https://datatracker.ietf.org/doc/html/rfc6749#section-5.2}RFC 6749
88 Section 5.2}. *)
89 | Token_refresh_failed of { reason : string }
90 (** Token refresh operation failed. *)
91 | Token_expired
92 (** Access token has expired and no refresh token is available. *)
93 (* HTTP/2 protocol errors - per RFC 9113 *)
94 | H2_protocol_error of { code : int32; message : string }
95 (** HTTP/2 connection error per
96 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.4.1}RFC
97 9113 Section 5.4.1}. Error codes are defined in RFC 9113 Section 7.
98 *)
99 | H2_stream_error of { stream_id : int32; code : int32; message : string }
100 (** HTTP/2 stream error per
101 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.4.2}RFC
102 9113 Section 5.4.2}. *)
103 | H2_flow_control_error of { stream_id : int32 option }
104 (** Flow control window exceeded per
105 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-5.2}RFC 9113
106 Section 5.2}. *)
107 | H2_compression_error of { message : string }
108 (** HPACK decompression failed per
109 {{:https://datatracker.ietf.org/doc/html/rfc7541}RFC 7541}. *)
110 | H2_settings_timeout
111 (** SETTINGS acknowledgment timeout per
112 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-6.5.3}RFC
113 9113 Section 6.5.3}. *)
114 | H2_goaway of { last_stream_id : int32; code : int32; debug : string }
115 (** Server sent GOAWAY frame per
116 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-6.8}RFC 9113
117 Section 6.8}. *)
118 | H2_frame_error of { frame_type : int; message : string }
119 (** Invalid frame received per RFC 9113 Section 4-6. *)
120 | H2_header_validation_error of { message : string }
121 (** HTTP/2 header validation failed per RFC 9113 Section 8.2-8.3. *)
122
123(** {1 Eio.Exn Integration} *)
124
125(** Extension of [Eio.Exn.err] for Requests errors *)
126type Eio.Exn.err += E of t
127
128val err : t -> exn
129(** [err e] creates an Eio exception from an error. *)
130
131(** {1 URL and Credential Sanitization} *)
132
133val sanitize_url : string -> string
134(** Remove userinfo (username:password) from a URL for safe logging. *)
135
136val sanitize_headers : (string * string) list -> (string * string) list
137(** Redact sensitive headers (Authorization, Cookie, etc.) for safe logging.
138 Takes and returns a list of (name, value) pairs. *)
139
140val is_sensitive_header : string -> bool
141(** Check if a header name is sensitive (case-insensitive). *)
142
143(** {1 Pretty Printing} *)
144
145val pp_error : Format.formatter -> t -> unit
146(** Pretty printer for error values. *)
147
148(** {1 Query Functions}
149
150 These functions enable smart error handling without pattern matching. *)
151
152val is_timeout : t -> bool
153(** [is_timeout e] returns [true] if [e] is a timeout. *)
154
155val is_dns : t -> bool
156(** [is_dns e] returns [true] if [e] is a DNS resolution failure. *)
157
158val is_tls : t -> bool
159(** [is_tls e] returns [true] if [e] is a TLS handshake failure. *)
160
161val is_connection : t -> bool
162(** [is_connection e] returns [true] if [e] is any connection-related failure
163 (DNS, TCP connect, or TLS handshake). *)
164
165val is_http_error : t -> bool
166(** [is_http_error e] returns [true] if [e] is an HTTP response error. *)
167
168val is_client_error : t -> bool
169(** [is_client_error e] returns [true] if [e] is a client error (4xx status or
170 similar). *)
171
172val is_server_error : t -> bool
173(** [is_server_error e] returns [true] if [e] is a server error (5xx status). *)
174
175val is_retryable : t -> bool
176(** [is_retryable e] returns [true] if [e] is typically retryable. Retryable
177 errors include: timeouts, connection errors, and certain HTTP status codes
178 (408, 429, 500, 502, 503, 504). *)
179
180val is_security_error : t -> bool
181(** [is_security_error e] returns [true] if [e] is security-related (header
182 injection, body too large, decompression bomb, etc.). *)
183
184val is_json_error : t -> bool
185(** [is_json_error e] returns [true] if [e] is a JSON parsing or encoding error.
186*)
187
188val is_oauth_error : t -> bool
189(** [is_oauth_error e] returns [true] if [e] is an OAuth-related error
190 (Oauth_error, Token_refresh_failed, Token_expired). *)
191
192(** {1 Error Extraction} *)
193
194val of_eio_exn : exn -> t option
195(** Extract error from an Eio.Io exception, if it's a Requests error. *)
196
197(** {1 HTTP Status Helpers} *)
198
199val http_status : t -> int option
200(** Get the HTTP status code from an error, if applicable. *)
201
202val url : t -> string option
203(** Get the URL associated with an error, if applicable. *)
204
205(** {1 String Conversion} *)
206
207val pp : Format.formatter -> t -> unit
208(** [pp ppf e] pretty-prints the error. *)
209
210val to_string : t -> string
211(** Convert error to human-readable string. *)
212
213(** {1 Convenience Constructors}
214
215 These functions provide a more concise way to create error exceptions
216 compared to the verbose [err (Error_type { field = value; ... })] pattern.
217
218 Example:
219 {[
220 (* Instead of: *)
221 raise
222 (err (Invalid_request { reason = "missing host" }))
223 (* Use: *)
224 raise
225 (invalid_request ~reason:"missing host")
226 ]} *)
227
228val invalid_request : reason:string -> exn
229(** [invalid_request ~reason] creates an [Invalid_request] exception. *)
230
231val invalid_redirect : url:string -> reason:string -> exn
232(** [invalid_redirect ~url ~reason] creates an [Invalid_redirect] exception. *)
233
234val invalid_url : url:string -> reason:string -> exn
235(** [invalid_url ~url ~reason] creates an [Invalid_url] exception. *)
236
237val timeout : operation:string -> ?duration:float -> unit -> exn
238(** [timeout ~operation ?duration ()] creates a [Timeout] exception. *)
239
240val body_too_large : limit:int64 -> ?actual:int64 -> unit -> exn
241(** [body_too_large ~limit ?actual ()] creates a [Body_too_large] exception. *)
242
243val headers_too_large : limit:int -> actual:int -> exn
244(** [headers_too_large ~limit ~actual] creates a [Headers_too_large] exception.
245*)
246
247val proxy_error : host:string -> reason:string -> exn
248(** [proxy_error ~host ~reason] creates a [Proxy_error] exception. *)
249
250val tls_handshake_failed : host:string -> reason:string -> exn
251(** [tls_handshake_failed ~host ~reason] creates a [Tls_handshake_failed]
252 exception. *)
253
254val tcp_connect_failed : host:string -> port:int -> reason:string -> exn
255(** [tcp_connect_failed ~host ~port ~reason] creates a [Tcp_connect_failed]
256 exception. *)
257
258(** {1 Format String Constructors}
259
260 These functions accept printf-style format strings for the reason field,
261 making error construction more concise when messages need interpolation.
262
263 Example:
264 {[
265 (* Instead of: *)
266 raise
267 (Error.err
268 (Error.Invalid_request
269 { reason = Fmt.str "Invalid status code: %s" code_str }))
270 (* Use: *)
271 raise
272 (Error.invalid_requestf "Invalid status code: %s" code_str)
273 ]} *)
274
275val invalid_requestf : ('a, Format.formatter, unit, exn) format4 -> 'a
276(** [invalid_requestf fmt] creates an [Invalid_request] exception with a format
277 string. *)
278
279val invalid_redirectf :
280 url:string -> ('a, Format.formatter, unit, exn) format4 -> 'a
281(** [invalid_redirectf ~url fmt] creates an [Invalid_redirect] exception with a
282 format string. *)
283
284val invalid_urlf : url:string -> ('a, Format.formatter, unit, exn) format4 -> 'a
285(** [invalid_urlf ~url fmt] creates an [Invalid_url] exception with a format
286 string. *)
287
288val proxy_errorf :
289 host:string -> ('a, Format.formatter, unit, exn) format4 -> 'a
290(** [proxy_errorf ~host fmt] creates a [Proxy_error] exception with a format
291 string. *)
292
293val tls_handshake_failedf :
294 host:string -> ('a, Format.formatter, unit, exn) format4 -> 'a
295(** [tls_handshake_failedf ~host fmt] creates a [Tls_handshake_failed] exception
296 with a format string. *)
297
298val tcp_connect_failedf :
299 host:string -> port:int -> ('a, Format.formatter, unit, exn) format4 -> 'a
300(** [tcp_connect_failedf ~host ~port fmt] creates a [Tcp_connect_failed]
301 exception with a format string. *)
302
303(** {1 OAuth Error Constructors} *)
304
305val oauth_error :
306 error_code:string -> ?description:string -> ?uri:string -> unit -> exn
307(** [oauth_error ~error_code ?description ?uri ()] creates an [Oauth_error]
308 exception. *)
309
310val token_refresh_failed : reason:string -> exn
311(** [token_refresh_failed ~reason] creates a [Token_refresh_failed] exception.
312*)
313
314val token_expired : unit -> exn
315(** [token_expired ()] creates a [Token_expired] exception. *)
316
317(** {1 HTTP/2 Error Query Functions}
318
319 Query functions for HTTP/2 specific errors per
320 {{:https://datatracker.ietf.org/doc/html/rfc9113}RFC 9113}. *)
321
322val is_h2_error : t -> bool
323(** [is_h2_error e] returns [true] if [e] is any HTTP/2 protocol error. *)
324
325val is_h2_connection_error : t -> bool
326(** [is_h2_connection_error e] returns [true] if [e] is an HTTP/2
327 connection-level error. Connection errors terminate the entire HTTP/2
328 connection. *)
329
330val is_h2_stream_error : t -> bool
331(** [is_h2_stream_error e] returns [true] if [e] is an HTTP/2 stream-level
332 error. Stream errors only affect a single stream. *)
333
334val is_h2_retryable : t -> bool
335(** [is_h2_retryable e] returns [true] if the HTTP/2 error is typically
336 retryable. Retryable errors include:
337 - GOAWAY with NO_ERROR (graceful shutdown)
338 - REFUSED_STREAM (server didn't process the request)
339 - ENHANCE_YOUR_CALM (after backoff). *)
340
341val h2_error_code : t -> int32 option
342(** Get the HTTP/2 error code from an error, if applicable. Error codes are
343 defined in RFC 9113 Section 7. *)
344
345val h2_stream_id : t -> int32 option
346(** Get the stream ID associated with an HTTP/2 error, if applicable. *)
347
348(** {1 HTTP/2 Error Constructors}
349
350 Convenience constructors for HTTP/2 errors per
351 {{:https://datatracker.ietf.org/doc/html/rfc9113#section-7}RFC 9113 Section
352 7}. *)
353
354val h2_protocol_error : code:int32 -> message:string -> exn
355(** [h2_protocol_error ~code ~message] creates an [H2_protocol_error] exception.
356*)
357
358val h2_stream_error : stream_id:int32 -> code:int32 -> message:string -> exn
359(** [h2_stream_error ~stream_id ~code ~message] creates an [H2_stream_error]
360 exception. *)
361
362val h2_flow_control_error : ?stream_id:int32 -> unit -> exn
363(** [h2_flow_control_error ?stream_id ()] creates an [H2_flow_control_error]
364 exception. If [stream_id] is provided, it's a stream-level error; otherwise,
365 it's a connection-level error. *)
366
367val h2_compression_error : message:string -> exn
368(** [h2_compression_error ~message] creates an [H2_compression_error] exception.
369*)
370
371val h2_settings_timeout : unit -> exn
372(** [h2_settings_timeout ()] creates an [H2_settings_timeout] exception. *)
373
374val h2_goaway : last_stream_id:int32 -> code:int32 -> debug:string -> exn
375(** [h2_goaway ~last_stream_id ~code ~debug] creates an [H2_goaway] exception.
376*)
377
378val h2_frame_error : frame_type:int -> message:string -> exn
379(** [h2_frame_error ~frame_type ~message] creates an [H2_frame_error] exception.
380*)
381
382val h2_header_validation_error : message:string -> exn
383(** [h2_header_validation_error ~message] creates an
384 [H2_header_validation_error] exception. *)
385
386(** {2 HTTP/2 Error Code Names} *)
387
388val h2_error_code_name : int32 -> string
389(** [h2_error_code_name code] returns the name of an HTTP/2 error code. Per RFC
390 9113 Section 7:
391 - 0x0: NO_ERROR
392 - 0x1: PROTOCOL_ERROR
393 - 0x2: INTERNAL_ERROR
394 - 0x3: FLOW_CONTROL_ERROR
395 - 0x4: SETTINGS_TIMEOUT
396 - 0x5: STREAM_CLOSED
397 - 0x6: FRAME_SIZE_ERROR
398 - 0x7: REFUSED_STREAM
399 - 0x8: CANCEL
400 - 0x9: COMPRESSION_ERROR
401 - 0xa: CONNECT_ERROR
402 - 0xb: ENHANCE_YOUR_CALM
403 - 0xc: INADEQUATE_SECURITY
404 - 0xd: HTTP_1_1_REQUIRED. *)