Module: Nfe::ErrorFactory

Defined in:
lib/nfe/error_factory.rb,
sig/nfe/error_factory.rbs

Overview

Translates HTTP responses and network exceptions into the typed Error hierarchy.

The transport returns 4xx/5xx as Http::Response objects (never raising); the resource layer feeds those to ErrorFactory.from_response to obtain the right error class. Network failures the transport raised are normalized via ErrorFactory.from_network_error.

The extracted message echoes server-controlled input, so it is capped to a bounded length and stripped of control characters before reaching the error — an attacker-controlled body must not flood logs or inject terminal escape sequences.

Constant Summary collapse

MAX_MESSAGE_LENGTH =

Maximum length of a message extracted from a response body.

Returns:

  • (Integer)
500
MESSAGE_KEYS =

Body keys searched (in order) for a human-readable message.

Returns:

  • (Array[String])
%w[message error detail details].freeze
ERROR_CODE_KEYS =

Body keys searched (in order) for a machine-readable error code.

Returns:

  • (Array[String])
%w[code errorCode error_code].freeze
CONTROL_CHARS =

ASCII control characters (C0 range plus DEL) scrubbed from messages.

Returns:

  • (Regexp)
/[\x00-\x1f\x7f]/

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.error_class_for(status) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/nfe/error_factory.rb', line 77

def error_class_for(status)
  case status
  when 401 then AuthenticationError
  when 403 then AuthorizationError
  when 404 then NotFoundError
  when 409 then ConflictError
  when 429 then RateLimitError
  when 400..499 then InvalidRequestError
  when 500..599 then ServerError
  else
    status >= 500 ? ServerError : InvalidRequestError
  end
end

.extract_error_code(parsed) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



141
142
143
144
145
146
147
148
149
# File 'lib/nfe/error_factory.rb', line 141

def extract_error_code(parsed)
  return nil unless parsed.is_a?(Hash)

  ERROR_CODE_KEYS.each do |key|
    value = parsed[key]
    return value.to_s if value.is_a?(String) || value.is_a?(Integer)
  end
  nil
end

.extract_message(parsed) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



101
102
103
104
105
106
# File 'lib/nfe/error_factory.rb', line 101

def extract_message(parsed)
  return nil unless parsed.is_a?(Hash)

  raw = message_from_keys(parsed) || message_from_errors(parsed["errors"])
  sanitize_message(raw)
end

.extract_retry_after(response) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



152
153
154
155
156
157
158
159
# File 'lib/nfe/error_factory.rb', line 152

def extract_retry_after(response)
  value = response.header("retry-after")
  return nil if value.nil?

  Integer(value, 10)
rescue ArgumentError, TypeError
  nil
end

.from_network_error(exception) ⇒ Nfe::ApiConnectionError

Normalize a network exception raised by the transport.

Parameters:

  • exception (Exception)

Returns:



65
66
67
68
69
70
71
72
73
74
# File 'lib/nfe/error_factory.rb', line 65

def from_network_error(exception)
  klass = timeout?(exception) ? TimeoutError : ApiConnectionError
  error = klass.new(exception.message)
  # Preserve the original exception as cause for debugging.
  begin
    raise error, error.message, cause: exception
  rescue klass => e
    e
  end
end

.from_response(response) ⇒ Nfe::Error

Build the appropriate Nfe::Error subclass for an HTTP response.

Parameters:

Returns:



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/nfe/error_factory.rb', line 37

def from_response(response)
  status = response.status
  parsed = parse_body(response.body)

  message = extract_message(parsed) || "API request failed with HTTP #{status}"
  error_code = extract_error_code(parsed)
  request_id = response.header("x-request-id") || response.header("x-correlation-id")

  kwargs = {
    status_code: status,
    request_id: request_id,
    error_code: error_code,
    response_body: response.body,
    response_headers: response.headers
  }

  klass = error_class_for(status)
  if klass == RateLimitError
    RateLimitError.new(message, retry_after: extract_retry_after(response), **kwargs)
  else
    klass.new(message, **kwargs)
  end
end

.message_from_errors(errors) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



118
119
120
121
122
123
124
125
126
# File 'lib/nfe/error_factory.rb', line 118

def message_from_errors(errors)
  case errors
  when String
    errors
  when Array
    first = errors[0]
    first.is_a?(Hash) ? first["message"] : first
  end
end

.message_from_keys(parsed) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



109
110
111
112
113
114
115
# File 'lib/nfe/error_factory.rb', line 109

def message_from_keys(parsed)
  MESSAGE_KEYS.each do |key|
    value = parsed[key]
    return value if value.is_a?(String) && !value.empty?
  end
  nil
end

.parse_body(body) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



92
93
94
95
96
97
98
# File 'lib/nfe/error_factory.rb', line 92

def parse_body(body)
  return nil if body.nil? || body.empty?

  JSON.parse(body)
rescue JSON::ParserError
  nil
end

.sanitize_message(raw) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



129
130
131
132
133
134
135
136
137
138
# File 'lib/nfe/error_factory.rb', line 129

def sanitize_message(raw)
  return nil if raw.nil?

  # Scrub ASCII control characters (incl. ESC) so an attacker-controlled
  # body cannot inject terminal escape sequences, then cap the length.
  text = raw.to_s.gsub(CONTROL_CHARS, " ").strip
  return nil if text.empty?

  text.length > MAX_MESSAGE_LENGTH ? "#{text[0, MAX_MESSAGE_LENGTH]}..." : text
end

.timeout?(exception) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


162
163
164
165
166
# File 'lib/nfe/error_factory.rb', line 162

def timeout?(exception)
  timeout_class_names = %w[Net::OpenTimeout Net::ReadTimeout Timeout::Error]
  ancestor_names = exception.class.ancestors.map(&:to_s)
  timeout_class_names.intersect?(ancestor_names)
end

Instance Method Details

#self?.error_class_forsingleton(Nfe::Error)

Parameters:

  • status (Integer)

Returns:



10
# File 'sig/nfe/error_factory.rbs', line 10

def self?.error_class_for: (Integer status) -> singleton(Nfe::Error)

#self?.extract_error_codeString?

Parameters:

  • parsed (Object)

Returns:

  • (String, nil)


16
# File 'sig/nfe/error_factory.rbs', line 16

def self?.extract_error_code: (untyped parsed) -> String?

#self?.extract_messageString?

Parameters:

  • parsed (Object)

Returns:

  • (String, nil)


12
# File 'sig/nfe/error_factory.rbs', line 12

def self?.extract_message: (untyped parsed) -> String?

#self?.extract_retry_afterInteger?

Parameters:

  • response (Object)

Returns:

  • (Integer, nil)


17
# File 'sig/nfe/error_factory.rbs', line 17

def self?.extract_retry_after: (untyped response) -> Integer?

#self?.from_network_errorNfe::ApiConnectionError

Parameters:

  • exception (Exception)

Returns:



9
# File 'sig/nfe/error_factory.rbs', line 9

def self?.from_network_error: (Exception exception) -> Nfe::ApiConnectionError

#self?.from_responseNfe::Error

Parameters:

  • response (Object)

Returns:



8
# File 'sig/nfe/error_factory.rbs', line 8

def self?.from_response: (untyped response) -> Nfe::Error

#self?.message_from_errorsObject

Parameters:

  • errors (Object)

Returns:

  • (Object)


14
# File 'sig/nfe/error_factory.rbs', line 14

def self?.message_from_errors: (untyped errors) -> untyped

#self?.message_from_keysString?

Parameters:

  • parsed (Hash[String, untyped])

Returns:

  • (String, nil)


13
# File 'sig/nfe/error_factory.rbs', line 13

def self?.message_from_keys: (Hash[String, untyped] parsed) -> String?

#self?.parse_bodyObject

Parameters:

  • body (String, nil)

Returns:

  • (Object)


11
# File 'sig/nfe/error_factory.rbs', line 11

def self?.parse_body: (String? body) -> untyped

#self?.sanitize_messageString?

Parameters:

  • raw (Object)

Returns:

  • (String, nil)


15
# File 'sig/nfe/error_factory.rbs', line 15

def self?.sanitize_message: (untyped raw) -> String?

#self?.timeout?Boolean

Parameters:

  • exception (Exception)

Returns:

  • (Boolean)


18
# File 'sig/nfe/error_factory.rbs', line 18

def self?.timeout?: (Exception exception) -> bool