Module: HumanTone::ErrorParser

Defined in:
lib/humantone/error_parser.rb

Constant Summary collapse

PARSE_FAIL_MESSAGE =
'Failed to parse HumanTone API response as JSON. See exception details.'
COERCION_FAIL_MESSAGE =
'Malformed response from HumanTone API. See exception details.'
RAW_BODY_LIMIT_BYTES =
4096
SUCCESS_RANGE =
(200..299)
SERVER_ERROR_RANGE =
(500..599)
CLIENT_ERROR_RANGE =
(400..499)
EXACT =
:exact
PREFIX =
:prefix
PATTERN =
:pattern
MATCH_RULES =

§4.7 Error catalog — ordered list of [status, match_strategy, pattern, endpoints, exception_class]. endpoints == :all means cross-endpoint; otherwise it’s a Set of allowed endpoint symbols. Match is attempted in declaration order.

[
  [401, EXACT,   'Missing or invalid Authorization header',
   :all,                Errors::AuthenticationError],
  [401, EXACT,   'Invalid API key format',
   :all,                Errors::AuthenticationError],
  [401, EXACT,   'Invalid API key',
   :all,                Errors::AuthenticationError],
  [401, EXACT,   'This API key has been revoked',
   :all,                Errors::AuthenticationError],
  [401, EXACT,   'User not found',
   :all,                Errors::AuthenticationError],

  [403, EXACT,   'Your current plan does not include API access. Please upgrade to continue.',
   :all,                Errors::PermissionError],

  [404, EXACT,   'Not Found',
   :all,                Errors::NotFoundError],
  [405, EXACT,   'Method not allowed',
   :all,                Errors::InvalidRequestError],

  [400, EXACT,   'Invalid JSON body',
   %i[humanize detect], Errors::InvalidRequestError],
  [400, EXACT,   'content is required',
   %i[humanize detect], Errors::InvalidRequestError],

  [400, EXACT,   'Text must be at least 30 words',
   %i[humanize],        Errors::InvalidRequestError],
  [400, PREFIX,  'Text exceeds the maximum',
   %i[humanize],        Errors::InvalidRequestError],
  [400, EXACT,   'Not enough credits',
   %i[humanize],        Errors::InsufficientCreditsError],
  [400, EXACT,   'humanization_level must be one of: standard, advanced, extreme',
   %i[humanize],        Errors::InvalidRequestError],
  [400, EXACT,   'output_format must be one of: html, text, markdown',
   %i[humanize],        Errors::InvalidRequestError],
  [400, EXACT,   'custom_instructions must be 1000 characters or fewer',
   %i[humanize],        Errors::InvalidRequestError],
  [400, EXACT,   'The advanced and extreme humanization levels are only available for English text',
   %i[humanize], Errors::InvalidRequestError],
  [400, PREFIX,  'Your request did not pass the safety check',
   %i[humanize], Errors::InvalidRequestError],

  [404, EXACT,   'Plan not found',
   %i[humanize account], Errors::NotFoundError],

  [500, EXACT,   'Internal server error',
   %i[humanize], Errors::APIError]
].freeze
ERROR_CODE_SUBSTRINGS =

§4.11 case-insensitive substring → error_code (Symbol). Order matters; first match wins. Used only in v1-shape (string body) flow.

[
  ['daily usage limit reached',   :daily_limit_exceeded],
  ['not enough credits',          :insufficient_credits],
  ['at least 30 words',           :text_too_short],
  ['exceeds the maximum',         :text_too_long],
  ['safety check',                :safety_check_failed],
  ['only available for english',  :language_not_supported]
].freeze
V2_CODE_TO_CLASS =

§4.11 reverse mapping for v2 shape: error_code Symbol → exception class.

{
  daily_limit_exceeded: Errors::DailyLimitExceededError,
  insufficient_credits: Errors::InsufficientCreditsError,
  text_too_short: Errors::InvalidRequestError,
  text_too_long: Errors::InvalidRequestError,
  safety_check_failed: Errors::InvalidRequestError,
  language_not_supported: Errors::InvalidRequestError,
  missing_api_key: Errors::InvalidRequestError,
  invalid_api_key_format: Errors::InvalidRequestError,
  authentication_error: Errors::AuthenticationError,
  permission_denied: Errors::PermissionError,
  not_found: Errors::NotFoundError,
  rate_limit: Errors::RateLimitError,
  api_error: Errors::APIError,
  network_error: Errors::NetworkError,
  timeout: Errors::TimeoutError,
  invalid_request: Errors::InvalidRequestError
}.freeze
HTTP_STATUS_FALLBACK =
{
  401 => [Errors::AuthenticationError, :authentication_error],
  403 => [Errors::PermissionError,     :permission_denied],
  404 => [Errors::NotFoundError,       :not_found],
  429 => [Errors::RateLimitError,      :rate_limit]
}.freeze
DEFAULT_4XX_FALLBACK =
[Errors::InvalidRequestError, :invalid_request].freeze
DEFAULT_5XX_FALLBACK =
[Errors::APIError, :api_error].freeze

Class Method Summary collapse

Class Method Details

.build_client_error(body:, status:, endpoint:, request_id:) ⇒ Object

— 4xx path —



291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/humantone/error_parser.rb', line 291

def build_client_error(body:, status:, endpoint:, request_id:)
  err = body.is_a?(Hash) ? body[:error] : nil

  if err.is_a?(Hash)
    build_v2_error(error_obj: err, status: status, request_id: request_id)
  elsif err.is_a?(String)
    build_v1_string_error(message: err, status: status, endpoint: endpoint, request_id: request_id)
  else
    # No usable error field — fall back by HTTP status with a synthesized message.
    klass, code = fallback_for_status(status)
    klass.new(default_message_for(status), status_code: status, request_id: request_id, error_code: code)
  end
end

.build_for_class(klass, message:, status:, request_id:, error_code:, details:) ⇒ Object



327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# File 'lib/humantone/error_parser.rb', line 327

def build_for_class(klass, message:, status:, request_id:, error_code:, details:)
  common = {
    status_code: status, request_id: request_id, error_code: error_code, details: details
  }

  case klass.name
  when 'HumanTone::Errors::InsufficientCreditsError'
    klass.new(message, **common,
              required_credits: details.is_a?(Hash) ? details[:required_credits] : nil,
              available_credits: details.is_a?(Hash) ? details[:available_credits] : nil)
  when 'HumanTone::Errors::DailyLimitExceededError'
    ttnr = details.is_a?(Hash) ? details[:time_to_next_renew] : nil
    klass.new(message, **common, time_to_next_renew: ttnr)
  when 'HumanTone::Errors::RateLimitError'
    klass.new(message, **common, retry_after_seconds: 0)
  else
    klass.new(message, **common)
  end
end

.build_rate_limit(body:, headers:, status:, request_id:) ⇒ Object

— 429 path —



263
264
265
266
267
268
269
270
271
272
273
# File 'lib/humantone/error_parser.rb', line 263

def build_rate_limit(body:, headers:, status:, request_id:)
  message = extract_message(body) || ''
  retry_after = parse_retry_after(headers&.[]('retry-after'))
  Errors::RateLimitError.new(
    message,
    status_code: status,
    request_id: request_id,
    error_code: :rate_limit,
    retry_after_seconds: retry_after
  )
end

.build_server_error(body:, status:, endpoint:, request_id:) ⇒ Object

— 5xx path —



402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/humantone/error_parser.rb', line 402

def build_server_error(body:, status:, endpoint:, request_id:)
  err = body.is_a?(Hash) ? body[:error] : nil

  if err.is_a?(Hash)
    build_v2_error(error_obj: err, status: status, request_id: request_id)
  elsif err.is_a?(String)
    # 5xx with v1 string — usually "Internal server error"
    matched_class = match_rule(status: status, endpoint: endpoint, message: err)
    klass = matched_class || Errors::APIError
    code = infer_error_code(err) || :api_error
    klass.new(err, status_code: status, request_id: request_id, error_code: code)
  else
    Errors::APIError.new(default_message_for(status),
                         status_code: status, request_id: request_id, error_code: :api_error)
  end
end

.build_success_false_error(body:, endpoint:, request_id:) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/humantone/error_parser.rb', line 214

def build_success_false_error(body:, endpoint:, request_id:)
  err = body[:error]

  if err.is_a?(String) && err.start_with?('Daily usage limit reached')
    return Errors::DailyLimitExceededError.new(
      err,
      status_code: 200,
      request_id: request_id,
      error_code: :daily_limit_exceeded,
      time_to_next_renew: body[:time_to_next_renew]
    )
  end

  case endpoint
  when :detect
    if err.nil?
      Errors::APIError.new(
        'Detect service returned success:false with no message',
        status_code: 200, request_id: request_id, error_code: :api_error, retryable: true
      )
    else
      Errors::APIError.new(
        err.to_s,
        status_code: 200, request_id: request_id, error_code: :api_error, retryable: false
      )
    end
  when :humanize
    if err.nil?
      Errors::APIError.new(
        'Humanize service returned success:false with no message',
        status_code: 200, request_id: request_id, error_code: :api_error, retryable: false
      )
    else
      Errors::APIError.new(
        err.to_s,
        status_code: 200, request_id: request_id, error_code: :api_error, retryable: false
      )
    end
  else
    # Defensive: account endpoint never returns success:false today.
    Errors::APIError.new(
      err.is_a?(String) ? err : 'Service returned success:false with no message',
      status_code: 200, request_id: request_id, error_code: :api_error, retryable: false
    )
  end
end

.build_v1_string_error(message:, status:, endpoint:, request_id:) ⇒ Object



305
306
307
308
309
310
311
# File 'lib/humantone/error_parser.rb', line 305

def build_v1_string_error(message:, status:, endpoint:, request_id:)
  matched_class = match_rule(status: status, endpoint: endpoint, message: message)
  klass = matched_class || fallback_for_status(status).first
  code = infer_error_code(message) || fallback_for_status(status).last
  build_for_class(klass, message: message, status: status, request_id: request_id,
                         error_code: code, details: nil)
end

.build_v2_error(error_obj:, status:, request_id:) ⇒ Object



313
314
315
316
317
318
319
320
321
322
323
324
325
# File 'lib/humantone/error_parser.rb', line 313

def build_v2_error(error_obj:, status:, request_id:)
  code_raw = error_obj[:code]
  code_sym = code_raw.is_a?(String) || code_raw.is_a?(Symbol) ? code_raw.to_sym : nil

  message = error_obj[:message].is_a?(String) ? error_obj[:message] : default_message_for(status)
  details = error_obj[:details] if error_obj[:details].is_a?(Hash)

  klass = (code_sym && V2_CODE_TO_CLASS[code_sym]) || fallback_for_status(status).first

  build_for_class(klass, message: message, status: status, request_id: request_id,
                         error_code: code_sym || fallback_for_status(status).last,
                         details: details)
end

.default_message_for(status) ⇒ Object



389
390
391
392
393
394
395
396
397
398
# File 'lib/humantone/error_parser.rb', line 389

def default_message_for(status)
  case status
  when 401 then 'Authentication failed'
  when 403 then 'Permission denied'
  when 404 then 'Not Found'
  when 429 then 'Rate limited'
  when SERVER_ERROR_RANGE then 'Server error'
  else 'Request failed'
  end
end

.endpoint_allowed?(endpoints, endpoint) ⇒ Boolean

Returns:

  • (Boolean)


364
365
366
367
368
369
# File 'lib/humantone/error_parser.rb', line 364

def endpoint_allowed?(endpoints, endpoint)
  return true if endpoints == :all
  return endpoints.include?(endpoint) if endpoints.is_a?(Array)

  false
end

.extract_message(body) ⇒ Object



419
420
421
422
423
424
425
426
427
# File 'lib/humantone/error_parser.rb', line 419

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

  err = body[:error]
  return err if err.is_a?(String)
  return err[:message] if err.is_a?(Hash) && err[:message].is_a?(String)

  nil
end

.fallback_for_status(status) ⇒ Object



379
380
381
382
383
384
385
386
387
# File 'lib/humantone/error_parser.rb', line 379

def fallback_for_status(status)
  HTTP_STATUS_FALLBACK[status] || (
    if SERVER_ERROR_RANGE.cover?(status)
      DEFAULT_5XX_FALLBACK
    else
      DEFAULT_4XX_FALLBACK
    end
  )
end

.handle_success(body:, body_string:, status:, request_id:, endpoint:, decoder:) ⇒ Object

— success path —



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/humantone/error_parser.rb', line 182

def handle_success(body:, body_string:, status:, request_id:, endpoint:, decoder:)
  unless body.is_a?(Hash)
    raise Errors::APIError.new(
      COERCION_FAIL_MESSAGE,
      status_code: status,
      request_id: request_id,
      details: { raw_body: truncate_body(body_string), coercion_error: 'expected JSON object at top level' },
      retryable: false,
      error_code: :api_error
    )
  end

  if body[:success].equal?(false)
    raise build_success_false_error(body: body, endpoint: endpoint, request_id: request_id)
  end

  run_decoder(body: body, body_string: body_string, status: status, request_id: request_id, decoder: decoder)
end

.infer_error_code(message) ⇒ Object



371
372
373
374
375
376
377
# File 'lib/humantone/error_parser.rb', line 371

def infer_error_code(message)
  lower = message.downcase
  ERROR_CODE_SUBSTRINGS.each do |needle, code|
    return code if lower.include?(needle)
  end
  nil
end

.match_rule(status:, endpoint:, message:) ⇒ Object



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/humantone/error_parser.rb', line 347

def match_rule(status:, endpoint:, message:)
  MATCH_RULES.each do |rule_status, strategy, pattern, endpoints, klass|
    next unless rule_status == status
    next unless endpoint_allowed?(endpoints, endpoint)

    case strategy
    when EXACT
      return klass if message == pattern
    when PREFIX
      return klass if message.start_with?(pattern)
    when PATTERN
      return klass if pattern.match?(message)
    end
  end
  nil
end

.parse(endpoint:, status:, headers:, body_string:, decoder:) ⇒ Object

Parses the raw HTTP response and returns the decoded success value by invoking ‘decoder.call(body_hash)`, or raises a HumanTone error.

Parameters:

  • endpoint (Symbol)

    :humanize, :detect, or :account

  • status (Integer)

    HTTP status code

  • headers (Hash{String=>String})

    response headers (lowercase keys)

  • body_string (String)

    raw response body

  • decoder (Proc)

    called with parsed body Hash; may raise Coercion::Error



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/humantone/error_parser.rb', line 125

def parse(endpoint:, status:, headers:, body_string:, decoder:)
  body = parse_json(body_string, status: status)
  request_id = resolve_request_id(body, headers)

  if SUCCESS_RANGE.cover?(status)
    handle_success(body: body, body_string: body_string, status: status,
                   request_id: request_id, endpoint: endpoint, decoder: decoder)
  elsif status == 429
    raise build_rate_limit(body: body, headers: headers, status: status, request_id: request_id)
  elsif CLIENT_ERROR_RANGE.cover?(status)
    raise build_client_error(body: body, status: status, endpoint: endpoint, request_id: request_id)
  elsif SERVER_ERROR_RANGE.cover?(status)
    raise build_server_error(body: body, status: status, endpoint: endpoint, request_id: request_id)
  else
    # Unexpected (1xx/3xx) — defensive: treat as APIError.
    raise Errors::APIError.new("Unexpected HTTP status #{status}",
                               status_code: status, request_id: request_id, error_code: :api_error)
  end
end

.parse_json(body_string, status:) ⇒ Object

—– internals —–



147
148
149
150
151
152
153
154
155
156
157
# File 'lib/humantone/error_parser.rb', line 147

def parse_json(body_string, status:)
  JSON.parse(body_string.to_s, symbolize_names: true)
rescue JSON::ParserError => e
  raise Errors::APIError.new(
    PARSE_FAIL_MESSAGE,
    status_code: status,
    details: { raw_body: truncate_body(body_string), parse_error: e.message },
    retryable: SERVER_ERROR_RANGE.cover?(status),
    error_code: :api_error
  )
end

.parse_retry_after(header) ⇒ Object



275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/humantone/error_parser.rb', line 275

def parse_retry_after(header)
  return 0 if header.nil?

  trimmed = header.to_s.strip
  return 0 if trimmed.empty?
  return trimmed.to_i if trimmed.match?(/\A\d+\z/)

  begin
    [(Time.httpdate(trimmed) - Time.now).to_i, 0].max
  rescue ArgumentError
    0
  end
end

.resolve_request_id(body, headers) ⇒ Object



159
160
161
162
163
164
165
166
167
# File 'lib/humantone/error_parser.rb', line 159

def resolve_request_id(body, headers)
  if body.is_a?(Hash)
    body_id = body[:request_id]
    return body_id if body_id.is_a?(String) && !body_id.empty?
  end

  header_id = headers&.[]('x-request-id')
  header_id if header_id.is_a?(String) && !header_id.empty?
end

.run_decoder(body:, body_string:, status:, request_id:, decoder:) ⇒ Object



201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/humantone/error_parser.rb', line 201

def run_decoder(body:, body_string:, status:, request_id:, decoder:)
  decoder.call(body, request_id)
rescue Coercion::Error => e
  raise Errors::APIError.new(
    COERCION_FAIL_MESSAGE,
    status_code: status,
    request_id: request_id,
    details: { raw_body: truncate_body(body_string), coercion_error: e.message },
    retryable: false,
    error_code: :api_error
  )
end

.truncate_body(body_string) ⇒ Object



169
170
171
172
173
174
175
176
177
178
# File 'lib/humantone/error_parser.rb', line 169

def truncate_body(body_string)
  s = body_string.to_s
  bytes = s.bytesize
  return s if bytes <= RAW_BODY_LIMIT_BYTES

  truncated = s.byteslice(0, RAW_BODY_LIMIT_BYTES).to_s
  # byteslice may have split a multibyte char; scrub to valid UTF-8.
  truncated = truncated.force_encoding('UTF-8').scrub
  "#{truncated}..."
end