patient_http 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +8 -7
  3. data/CHANGELOG.md +29 -0
  4. data/README.md +539 -515
  5. data/VERSION +1 -1
  6. data/lib/patient_http/callback_args.rb +53 -48
  7. data/lib/patient_http/callback_validator.rb +11 -7
  8. data/lib/patient_http/class_helper.rb +6 -7
  9. data/lib/patient_http/client.rb +28 -22
  10. data/lib/patient_http/client_pool.rb +134 -39
  11. data/lib/patient_http/completion_executor.rb +20 -20
  12. data/lib/patient_http/configuration.rb +367 -119
  13. data/lib/patient_http/connection_endpoint.rb +150 -0
  14. data/lib/patient_http/encryptor.rb +28 -18
  15. data/lib/patient_http/error.rb +24 -18
  16. data/lib/patient_http/external_storage.rb +42 -38
  17. data/lib/patient_http/http_error.rb +30 -26
  18. data/lib/patient_http/http_headers.rb +35 -29
  19. data/lib/patient_http/immediate_retries.rb +98 -0
  20. data/lib/patient_http/inline_task_handler.rb +15 -10
  21. data/lib/patient_http/lifecycle_manager.rb +39 -40
  22. data/lib/patient_http/outgoing_request.rb +25 -23
  23. data/lib/patient_http/payload.rb +28 -26
  24. data/lib/patient_http/payload_store/active_record_store.rb +31 -34
  25. data/lib/patient_http/payload_store/base.rb +42 -46
  26. data/lib/patient_http/payload_store/file_store.rb +22 -26
  27. data/lib/patient_http/payload_store/redis_store.rb +28 -34
  28. data/lib/patient_http/payload_store/s3_store.rb +25 -28
  29. data/lib/patient_http/payload_store.rb +2 -0
  30. data/lib/patient_http/processor.rb +111 -79
  31. data/lib/patient_http/processor_observer.rb +65 -59
  32. data/lib/patient_http/rails/engine.rb +13 -8
  33. data/lib/patient_http/redirect_error.rb +50 -41
  34. data/lib/patient_http/redirect_helper.rb +38 -38
  35. data/lib/patient_http/request.rb +70 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +142 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +113 -84
  40. data/lib/patient_http/request_template.rb +87 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +66 -65
  43. data/lib/patient_http/secret_manager.rb +34 -30
  44. data/lib/patient_http/secret_reference.rb +33 -26
  45. data/lib/patient_http/synchronous_executor.rb +67 -95
  46. data/lib/patient_http/task_handler.rb +23 -19
  47. data/lib/patient_http/time_helper.rb +8 -8
  48. data/lib/patient_http.rb +311 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +21 -5
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require "socket"
5
+
6
+ module PatientHttp
7
+ # Wraps an HTTP endpoint to configure each connection as it is established.
8
+ #
9
+ # When a connection timeout is given, establishing the connection (the TCP
10
+ # connect and the TLS handshake) is bounded by a fiber scheduler timeout that
11
+ # raises `IO::TimeoutError`. The timeout is not set as the socket's
12
+ # `IO#timeout`, which would also limit every later read and write for as long
13
+ # as the connection lives, so once the connection is established the request
14
+ # timeout alone governs the exchange.
15
+ #
16
+ # When TCP keepalive settings are given, they are applied to each TCP socket.
17
+ # Keepalive probes keep the mappings of NAT gateways and stateful firewalls
18
+ # alive while a pooled connection is idle, and let the kernel detect a dead
19
+ # peer so the connection is retired before a request is sent on it.
20
+ #
21
+ # When a TCP user timeout is given, the kernel aborts a connection whose
22
+ # transmitted data stays unacknowledged for that long, so a request sent to a
23
+ # peer that has silently gone away fails quickly instead of waiting for the
24
+ # request timeout. Data the peer has acknowledged is not affected, so a slow
25
+ # response is never cut short. Only Linux supports this option.
26
+ #
27
+ # @api private
28
+ class ConnectionEndpoint < SimpleDelegator
29
+ # @return [Numeric, nil] Seconds allowed to establish a connection.
30
+ attr_reader :connection_timeout
31
+
32
+ # @return [Hash, nil] The keepalive settings with :idle, :interval, and :count.
33
+ attr_reader :tcp_keepalive
34
+
35
+ # @return [Numeric, nil] Seconds transmitted data may stay unacknowledged.
36
+ attr_reader :tcp_user_timeout
37
+
38
+ # @param endpoint [Async::HTTP::Endpoint] The endpoint to wrap.
39
+ # @param connection_timeout [Numeric, nil] Seconds allowed to establish a
40
+ # connection, or nil for no limit beyond the endpoint's own.
41
+ # @param tcp_keepalive [Hash, nil] Keepalive settings with :idle, :interval, and
42
+ # :count in seconds and probes (:interval and :count optional), or nil to leave
43
+ # the kernel defaults.
44
+ # @param tcp_user_timeout [Numeric, nil] Seconds transmitted data may stay
45
+ # unacknowledged, or nil to leave the kernel default.
46
+ def initialize(endpoint, connection_timeout: nil, tcp_keepalive: nil, tcp_user_timeout: nil)
47
+ super(endpoint)
48
+ @connection_timeout = connection_timeout
49
+ @tcp_keepalive = tcp_keepalive
50
+ @tcp_user_timeout = tcp_user_timeout
51
+ end
52
+
53
+ # Connects to the wrapped endpoint and configures the socket.
54
+ #
55
+ # @yield [socket] The connected socket, closed when the block returns.
56
+ # @return [IO] The connected socket when no block is given.
57
+ def connect
58
+ socket = connect_within_timeout
59
+ begin
60
+ apply_tcp_keepalive(socket)
61
+ apply_tcp_user_timeout(socket)
62
+ rescue
63
+ socket.close
64
+ raise
65
+ end
66
+
67
+ return socket unless block_given?
68
+
69
+ begin
70
+ yield socket
71
+ ensure
72
+ socket.close
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def connect_within_timeout
79
+ task = ::Async::Task.current?
80
+ return __getobj__.connect unless @connection_timeout && task
81
+
82
+ task.with_timeout(@connection_timeout, ::IO::TimeoutError, "Connect timed out") do
83
+ __getobj__.connect
84
+ end
85
+ end
86
+
87
+ # Keepalive is a TCP feature, so it is skipped for the socket pair behind a
88
+ # proxy tunnel. The kernel constants differ by platform: Linux names the idle
89
+ # time TCP_KEEPIDLE, macOS names it TCP_KEEPALIVE. A socket that rejects an
90
+ # option keeps working without it, and an interval or count that is not
91
+ # given keeps the kernel default.
92
+ #
93
+ # Top-level constants are written with a leading `::` because Delegator
94
+ # descends from BasicObject, where `defined?` cannot see them.
95
+ def apply_tcp_keepalive(socket)
96
+ return unless @tcp_keepalive
97
+
98
+ raw_socket = tcp_socket(socket)
99
+ return unless raw_socket
100
+
101
+ raw_socket.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_KEEPALIVE, true)
102
+ if (idle_option = keepalive_idle_option)
103
+ raw_socket.setsockopt(::Socket::IPPROTO_TCP, idle_option, @tcp_keepalive[:idle])
104
+ end
105
+ interval = @tcp_keepalive[:interval]
106
+ if interval && defined?(::Socket::TCP_KEEPINTVL)
107
+ raw_socket.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_KEEPINTVL, interval)
108
+ end
109
+ count = @tcp_keepalive[:count]
110
+ if count && defined?(::Socket::TCP_KEEPCNT)
111
+ raw_socket.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_KEEPCNT, count)
112
+ end
113
+ rescue ::SystemCallError
114
+ nil
115
+ end
116
+
117
+ def keepalive_idle_option
118
+ if defined?(::Socket::TCP_KEEPIDLE)
119
+ ::Socket::TCP_KEEPIDLE
120
+ elsif defined?(::Socket::TCP_KEEPALIVE)
121
+ ::Socket::TCP_KEEPALIVE
122
+ end
123
+ end
124
+
125
+ # The kernel takes the user timeout in milliseconds, and a value of zero
126
+ # restores its default instead of enforcing a limit, so a positive duration
127
+ # is rounded up to at least one millisecond. Platforms without the option
128
+ # keep their default retransmission limits.
129
+ def apply_tcp_user_timeout(socket)
130
+ return unless @tcp_user_timeout && defined?(::Socket::TCP_USER_TIMEOUT)
131
+
132
+ raw_socket = tcp_socket(socket)
133
+ return unless raw_socket
134
+
135
+ milliseconds = (@tcp_user_timeout * 1000).ceil
136
+ raw_socket.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_USER_TIMEOUT, milliseconds)
137
+ rescue ::SystemCallError
138
+ nil
139
+ end
140
+
141
+ # The TCP socket behind a plain or TLS connection, or nil for a socket that
142
+ # is not TCP, such as the socket pair behind a proxy tunnel.
143
+ def tcp_socket(socket)
144
+ raw_socket = socket.respond_to?(:to_io) ? socket.to_io : socket
145
+ return nil unless raw_socket.is_a?(::BasicSocket) && raw_socket.local_address.ip?
146
+
147
+ raw_socket
148
+ end
149
+ end
150
+ end
@@ -1,27 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Handles encryption and decryption of payloads for secure storage.
4
+ # Encrypts payloads before they're stored in a job queue, and decrypts them
5
+ # after they're read.
5
6
  #
6
- # This class provides a simple interface for encrypting data before storage and
7
- # decrypting it after retrieval. It supports pluggable encryption and decryption
8
- # logic, allowing you to use any encryption library or method that fits your needs.
7
+ # The encryptor serializes a hash to JSON, passes the bytes to the encryption
8
+ # callable, and stores the Base64-encoded result as
9
+ # `{"__encrypted__" => true, "value" => "<base64>"}`. You can use any
10
+ # encryption library.
11
+ #
12
+ # @see Configuration#encryptor
9
13
  class Encryptor
10
- # Initialize a new Encryptor with optional encryption and decryption callables.
14
+ # Creates an encryptor. Without callables, the encryptor returns data
15
+ # unchanged.
11
16
  #
12
- # @param encryption [#call, nil] A callable object that takes data and returns encrypted data
13
- # @param decryption [#call, nil] A callable object that takes encrypted data and returns decrypted data
17
+ # @param encryption [#call, nil] An object that takes the bytes as a String
18
+ # and returns the encrypted bytes.
19
+ # @param decryption [#call, nil] An object that takes the encrypted bytes and
20
+ # returns the decrypted bytes.
14
21
  def initialize(encryption: nil, decryption: nil)
15
22
  @encryption = encryption
16
23
  @decryption = decryption
17
24
  end
18
25
 
19
- # Encrypt data using the provided encryption callable. If no encryption callable is set,
20
- # returns the original data.
26
+ # Encrypts a hash. If no encryption callable is set, the hash is returned
27
+ # unchanged.
21
28
  #
22
- # @param data [Hash] The data to be encrypted
23
- # @return [Hash, nil] The encrypted data as a hash or the original data if no encryption callable is set
24
- # @raise [JSON::GeneratorError] If the data cannot be serialized to JSON
29
+ # @param data [Hash, nil] The data to encrypt.
30
+ # @return [Hash, nil] The encrypted data, or the original data if no
31
+ # encryption callable is set.
32
+ # @raise [ArgumentError] If the data isn't a Hash or `nil`.
33
+ # @raise [JSON::GeneratorError] If the data can't be serialized to JSON.
25
34
  def encrypt(data)
26
35
  return nil if data.nil?
27
36
 
@@ -37,13 +46,14 @@ module PatientHttp
37
46
  }
38
47
  end
39
48
 
40
- # Decrypt data using the provided decryption callable. If no decryption callable is set,
41
- # or if the data is not marked as encrypted, returns the original data.
49
+ # Decrypts a hash. If no decryption callable is set, or if the hash isn't
50
+ # encrypted, the hash is returned unchanged. As a result, data written
51
+ # before encryption was turned on can still be read.
42
52
  #
43
- # @param data [Hash] The data to be decrypted
44
- # @return [Hash, nil] The decrypted data as a hash or the original data if no decryption callable
45
- # is set or if data is not encrypted
46
- # @raise [JSON::ParserError] If the decrypted data cannot be parsed as JSON
53
+ # @param data [Hash, nil] The data to decrypt.
54
+ # @return [Hash, nil] The decrypted data, or the original data.
55
+ # @raise [ArgumentError] If the data isn't a Hash or `nil`.
56
+ # @raise [JSON::ParserError] If the decrypted data isn't valid JSON.
47
57
  def decrypt(data)
48
58
  return nil if data.nil?
49
59
 
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Base error class for async HTTP errors. This is an abstract class that
5
- # defines the common error interface.
4
+ # The abstract base class for errors that a request passes to the `on_error`
5
+ # callback. It defines the methods that all of these errors have.
6
+ #
7
+ # The subclasses are {HttpError}, {RedirectError}, and {RequestError}.
6
8
  class Error < StandardError
7
9
  class << self
8
- # Load an error from a hash, dispatching to the appropriate subclass.
10
+ # Creates an error from its serialized form. The hash determines the
11
+ # subclass.
9
12
  #
10
- # @param hash [Hash] hash representation of the error
11
- # @return [Error] the reconstructed error
13
+ # @param hash [Hash] The hash from {#as_json}.
14
+ # @return [Error] The error.
12
15
  def load(hash)
13
16
  # Dispatch based on hash structure
14
17
  if hash.key?("response")
@@ -21,54 +24,57 @@ module PatientHttp
21
24
  end
22
25
  end
23
26
 
24
- # Returns the error type symbol. Provided for compatibility with RequestError.
27
+ # Returns the error type. Subclasses return a more specific value.
25
28
  #
26
- # @return [Symbol] the error type
29
+ # @return [Symbol] The error type.
27
30
  def error_type
28
31
  :unknown
29
32
  end
30
33
 
31
- # @return [String] Request URL
34
+ # @return [String] The request URL.
32
35
  def url
33
36
  raise NotImplementedError, "Subclasses must implement #url"
34
37
  end
35
38
 
36
- # @return [Symbol] HTTP method
39
+ # @return [Symbol] The HTTP method.
37
40
  def http_method
38
41
  raise NotImplementedError, "Subclasses must implement #http_method"
39
42
  end
40
43
 
41
- # @return [Float] Request duration in seconds
44
+ # @return [Float] The request duration in seconds.
42
45
  def duration
43
46
  raise NotImplementedError, "Subclasses must implement #duration"
44
47
  end
45
48
 
46
- # @return [String] Unique request identifier
49
+ # @return [String] The unique request ID.
47
50
  def request_id
48
51
  raise NotImplementedError, "Subclasses must implement #request_id"
49
52
  end
50
53
 
51
- # @return [Class] the class of the exception that caused the error
54
+ # @return [Class] The class of the exception that caused the error.
52
55
  def error_class
53
56
  raise NotImplementedError, "Subclasses must implement #error_class"
54
57
  end
55
58
 
56
- # @return [CallbackArgs] the callback arguments
59
+ # @return [CallbackArgs] The callback arguments that were passed with the
60
+ # request.
57
61
  def callback_args
58
62
  raise NotImplementedError, "Subclasses must implement #callback_args"
59
63
  end
60
64
 
61
- # Serialize to a hash for JSON encoding. Subclasses must implement this.
65
+ # Returns the error as a JSON-compatible hash. Subclasses must implement
66
+ # this method.
62
67
  #
63
- # @return [Hash] hash representation of the error
68
+ # @return [Hash] The serialized error.
64
69
  def as_json
65
70
  raise NotImplementedError, "Subclasses must implement #as_json"
66
71
  end
67
72
 
68
- # Serialize to JSON string.
73
+ # Returns the error as a JSON string.
69
74
  #
70
- # @param options [Hash] options to pass to JSON.generate (for ActiveSupport compatibility)
71
- # @return [String] JSON representation
75
+ # @param options [Hash, nil] The options for `JSON.generate`. This parameter
76
+ # makes the method compatible with Active Support.
77
+ # @return [String] The JSON string.
72
78
  def to_json(options = nil)
73
79
  JSON.generate(as_json, options)
74
80
  end
@@ -1,18 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Handles external storage of large payloads.
4
+ # Stores large payloads in the registered payload store, and replaces them
5
+ # with a reference.
5
6
  #
6
- # This class provides methods for storing, fetching, and deleting
7
- # payloads from external storage. It is decoupled from the models
8
- # being stored (Request, Response, Error).
7
+ # A reference has the form `{"$ref" => {"store" => name, "key" => key}}`. This
8
+ # class works with any JSON-compatible hash, such as a serialized {Request},
9
+ # {Response}, or {Error}.
9
10
  #
10
- # @example Storing a large payload
11
+ # @example Store a payload
11
12
  # external_storage = ExternalStorage.new(config)
12
13
  # data = response.as_json
13
14
  # stored_data = external_storage.store(data)
14
15
  #
15
- # @example Fetching and deleting
16
+ # @example Fetch and delete a payload
16
17
  # if ExternalStorage.storage_ref?(data)
17
18
  # external_storage = ExternalStorage.new(config)
18
19
  # original_data = external_storage.fetch(data)
@@ -20,59 +21,61 @@ module PatientHttp
20
21
  # external_storage.delete(data)
21
22
  # end
22
23
  class ExternalStorage
23
- # Key used in serialized JSON to indicate an external storage reference
24
+ # The key that identifies a storage reference in serialized JSON.
24
25
  REFERENCE_KEY = "$ref"
25
26
 
27
+ # Raised when no payload store is registered, or when a reference names a
28
+ # store that isn't registered.
26
29
  class PayloadStoreNotFoundError < StandardError; end
30
+
31
+ # Raised when a referenced payload isn't in the store.
27
32
  class PayloadNotFoundError < StandardError; end
28
33
 
29
34
  class << self
30
- # Check if a hash is a storage reference.
35
+ # Returns whether data is a storage reference.
31
36
  #
32
- # @param data [Hash, Object] Data to check
33
- # @return [Boolean] true if this is a reference to external storage
37
+ # @param data [Hash, Object] The data to check.
38
+ # @return [Boolean] `true` if the data is a storage reference.
34
39
  def storage_ref?(data)
35
40
  data.is_a?(Hash) && data.key?(REFERENCE_KEY)
36
41
  end
37
42
  end
38
43
 
39
- # @return [Configuration] the pool configuration
44
+ # @return [Configuration] The configuration that has the payload stores.
40
45
  attr_reader :config
41
46
 
42
- # Create a new ExternalStorage instance.
47
+ # Creates an external storage object.
43
48
  #
44
- # @param config [Configuration] the pool configuration
49
+ # @param config [Configuration] The configuration that has the payload
50
+ # stores.
45
51
  def initialize(config)
46
52
  @config = config
47
53
  end
48
54
 
49
- # Check if a hash is a storage reference.
55
+ # Returns whether data is a storage reference.
50
56
  #
51
- # @param data [Hash, Object] Data to check
52
- # @return [Boolean] true if this is a reference to external storage
57
+ # @param data [Hash, Object] The data to check.
58
+ # @return [Boolean] `true` if the data is a storage reference.
53
59
  def storage_ref?(data)
54
60
  self.class.storage_ref?(data)
55
61
  end
56
62
 
57
- # Check if external storage is enabled (i.e. a payload store is configured).
63
+ # Returns whether a payload store is registered.
58
64
  #
59
- # @return [Boolean] true if external storage is configured
65
+ # @return [Boolean] `true` if a payload store is registered.
60
66
  def enabled?
61
67
  !!config.payload_store
62
68
  end
63
69
 
64
- # Store a hash externally if it exceeds the configured threshold.
65
- #
66
- # If no payload store is configured, or if the hash is below the
67
- # threshold, the original hash is returned unchanged.
70
+ # Stores a hash in the default payload store, and returns a reference to
71
+ # it.
68
72
  #
69
- # @param data [Hash] Hash to potentially store
70
- # @param max_size [Integer, nil] Optional payload size threshold in bytes.
71
- # The JSON payload will only be stored externally if it exceeds this size.
72
- # If the JSON payload does not exceed the threshold, the original hash is returned.
73
- # When nil (the default), the payload is always stored externally.
74
- # @return [Hash] Reference hash if stored, original hash if not
75
- # @raise [PayloadStoreNotFoundError] If no payload store is configured
73
+ # @param data [Hash] The hash to store.
74
+ # @param max_size [Integer, nil] The size in bytes above which the hash is
75
+ # stored. If the JSON is this size or smaller, the original hash is
76
+ # returned. If `nil`, the hash is always stored.
77
+ # @return [Hash] The reference, or the original hash if it wasn't stored.
78
+ # @raise [PayloadStoreNotFoundError] If no payload store is registered.
76
79
  def store(data, max_size: nil)
77
80
  store = config.payload_store
78
81
  raise PayloadStoreNotFoundError.new("No payload store configured") unless store
@@ -91,12 +94,13 @@ module PatientHttp
91
94
  }
92
95
  end
93
96
 
94
- # Fetch a hash from external storage.
97
+ # Fetches a stored hash.
95
98
  #
96
- # @param data [Hash] Reference hash containing storage location
97
- # @return [Hash] Original hash from storage
98
- # @raise [PayloadStoreNotFoundError] If the store is not registered
99
- # @raise [PayloadNotFoundError] If the stored payload is not found
99
+ # @param data [Hash] The reference.
100
+ # @return [Hash] The stored hash.
101
+ # @raise [ArgumentError] If the data isn't a storage reference.
102
+ # @raise [PayloadStoreNotFoundError] If the store isn't registered.
103
+ # @raise [PayloadNotFoundError] If the payload isn't in the store.
100
104
  def fetch(data)
101
105
  raise ArgumentError.new("Not a storage reference") unless self.class.storage_ref?(data)
102
106
 
@@ -113,12 +117,12 @@ module PatientHttp
113
117
  stored_data
114
118
  end
115
119
 
116
- # Delete payload from external storage.
120
+ # Deletes a stored payload.
117
121
  #
118
- # This method is idempotent - it's safe to call on non-reference hashes,
119
- # already-deleted payloads, or nil values.
122
+ # This method is idempotent. You can call it with `nil`, with a hash that
123
+ # isn't a reference, or with a reference to a deleted payload.
120
124
  #
121
- # @param data [Hash, nil] Reference hash (or regular hash, which is ignored)
125
+ # @param data [Hash, nil] The reference. Other values are ignored.
122
126
  # @return [void]
123
127
  def delete(data)
124
128
  return unless data && self.class.storage_ref?(data)
@@ -1,23 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Error raised when an HTTP request receives a non-2xx response status code
5
- # and the raise_error_responses option is enabled.
4
+ # The error for a non-2xx response when the `raise_error_responses` option is
5
+ # set.
6
6
  #
7
- # This error includes the full Response object so you can access the status code,
8
- # headers, body, and other response data.
7
+ # The error holds the full {Response}, so you can read the status, headers,
8
+ # and body.
9
9
  class HttpError < Error
10
- # @return [Response] The HTTP response that triggered the error
10
+ # @return [Response] The HTTP response that caused the error.
11
11
  attr_reader :response
12
12
 
13
13
  class << self
14
- # Create a new HttpError (or subclass) from a response.
14
+ # Creates an error for a response. The status determines the class: a
15
+ # {ClientError} for 4xx, a {ServerError} for 5xx, and an `HttpError` for
16
+ # other non-2xx statuses.
15
17
  #
16
- # Returns ClientError for 4xx responses, ServerError for 5xx responses,
17
- # or HttpError for other non-2xx responses.
18
- #
19
- # @param response [Response] The HTTP response with non-2xx status code
20
- # @return [HttpError, ClientError, ServerError] The appropriate error instance
18
+ # @param response [Response] The HTTP response with a non-2xx status.
19
+ # @return [HttpError, ClientError, ServerError] The error.
21
20
  def new(response)
22
21
  if response.client_error?
23
22
  ClientError.allocate.tap { |error| error.send(:initialize, response) }
@@ -28,65 +27,72 @@ module PatientHttp
28
27
  end
29
28
  end
30
29
 
31
- # Reconstruct an HttpError from a hash
30
+ # Creates an error from its serialized form.
32
31
  #
33
- # @param hash [Hash] hash representation
34
- # @return [HttpError] reconstructed error
32
+ # @param hash [Hash] The hash from {#as_json}.
33
+ # @return [HttpError] The error.
35
34
  def load(hash)
36
35
  response = Response.load(hash["response"])
37
36
  new(response)
38
37
  end
39
38
  end
40
39
 
41
- # Initializes a new HttpError.
40
+ # Creates an error.
42
41
  #
43
- # @param response [Response] The HTTP response with non-2xx status code
42
+ # @param response [Response] The HTTP response with a non-2xx status.
44
43
  def initialize(response)
45
44
  super("HTTP #{response.status} response from #{response.http_method.to_s.upcase} #{response.url}")
46
45
  @response = response
47
46
  end
48
47
 
49
- # Delegate common response methods for convenience.
48
+ # Returns the HTTP status of the response.
50
49
  #
51
- # @return [Integer] HTTP status code
50
+ # @return [Integer] The HTTP status code.
52
51
  def status
53
52
  @response.status
54
53
  end
55
54
 
56
- # Returns the error type symbol. Provided for compatibility with RequestError.
55
+ # Returns the error type.
57
56
  #
58
- # @return [Symbol] the error type
57
+ # @return [Symbol] Always `:http_error`.
59
58
  def error_type
60
59
  :http_error
61
60
  end
62
61
 
62
+ # @return [String] The request URL.
63
63
  def url
64
64
  response.url
65
65
  end
66
66
 
67
+ # @return [Symbol] The HTTP method.
67
68
  def http_method
68
69
  response.http_method
69
70
  end
70
71
 
72
+ # @return [Float] The request duration in seconds.
71
73
  def duration
72
74
  response.duration
73
75
  end
74
76
 
77
+ # @return [String] The unique request ID.
75
78
  def request_id
76
79
  response.request_id
77
80
  end
78
81
 
82
+ # @return [Class] The class of this error.
79
83
  def error_class
80
84
  self.class
81
85
  end
82
86
 
87
+ # @return [CallbackArgs] The callback arguments that were passed with the
88
+ # request.
83
89
  def callback_args
84
90
  response.callback_args
85
91
  end
86
92
 
87
- # Convert to hash with string keys for serialization
93
+ # Returns the error as a JSON-compatible hash.
88
94
  #
89
- # @return [Hash] hash representation
95
+ # @return [Hash] The serialized error.
90
96
  def as_json
91
97
  {
92
98
  "response" => @response.as_json
@@ -94,13 +100,11 @@ module PatientHttp
94
100
  end
95
101
  end
96
102
 
97
- # Error raised when an HTTP request receives a 4xx (client error) response status code
98
- # and the raise_error_responses option is enabled.
103
+ # The error for a 4xx response when the `raise_error_responses` option is set.
99
104
  class ClientError < HttpError
100
105
  end
101
106
 
102
- # Error raised when an HTTP request receives a 5xx (server error) response status code
103
- # and the raise_error_responses option is enabled.
107
+ # The error for a 5xx response when the `raise_error_responses` option is set.
104
108
  class ServerError < HttpError
105
109
  end
106
110
  end