we-promise/sure · error · Provider::Sophtron::Error

fetch_failed

fetch_failed

Error message

Sophtron API request failed: #{response.code} #{response.message} - #{body}

What it means

Provider::Sophtron's catch-all branch: any HTTP status not explicitly mapped (i.e. anything besides 200/201/204/400/401/403/404/429 - typically 5xx and unexpected 3xx/4xx) raises Error with error_type=:fetch_failed. The message interpolates response.code, response.message and the body, so the exact upstream failure is visible.

Source

Thrown at app/models/provider/sophtron.rb:367

      body = response.body.to_s

      case response.code.to_i
      when 200, 201, 204
        return {} if body.strip.blank?

        parse_json ? JSON.parse(body, symbolize_names: true) : parse_optional_json(body)
      when 400
        raise Error.new("Bad request to Sophtron API: #{body}", :bad_request, details: body)
      when 401
        raise Error.new("Invalid Sophtron User ID or Access Key", :unauthorized, details: body)
      when 403
        raise Error.new("Access forbidden by Sophtron", :access_forbidden, details: body)
      when 404
        raise Error.new("Sophtron resource not found", :not_found, details: body)
      when 429
        raise Error.new("Sophtron rate limit exceeded. Please try again later.", :rate_limited, details: body)
      else
        raise Error.new(
          "Sophtron API request failed: #{response.code} #{response.message} - #{body}",
          :fetch_failed,
          details: body
        )
      end
    rescue JSON::ParserError => e
      raise Error.new("Invalid JSON response from Sophtron API: #{e.message}", :invalid_response, details: body)
    end

    def parse_optional_json(body)
      JSON.parse(body, symbolize_names: true)
    rescue JSON::ParserError
      body
    end

    def normalize_base_url(value)
      url = value.presence || DEFAULT_BASE_URL
      url = url.to_s.chomp("/")

View on GitHub (pinned to e69894adb9)

Solutions

  1. Retry with backoff - 5xx from an aggregator is usually transient
  2. Check status.sophtron.com or Sophtron support for an ongoing incident before debugging code
  3. Log response.code/response.message from the error message to distinguish 5xx (transient) from unmapped 4xx (contract change)
  4. If a new 4xx appears consistently (e.g. 422), map it to its own error_type branch instead of leaving it in the catch-all

Example fix

# before
SophtronItem::Importer.new(item, sophtron_provider: p, sync: sync).import

# after - retry transient 5xx
begin
  SophtronItem::Importer.new(item, sophtron_provider: p, sync: sync).import
rescue Provider::Sophtron::Error => e
  retry if e.error_type == :fetch_failed && e.message.match?(/\b5\d\d\b/) && (retries += 1) < 3 && sleep(2 ** retries)
  raise
end
Defensive patterns

Strategy: retry

Validate before calling

null

Type guard

def sophtron_fetch_failed_5xx?(err)
  err.is_a?(Provider::Sophtron::Error) && err.error_type == :fetch_failed && err.message.match?(/\b5\d\d\b/)
end

Try / catch

retries = 0
begin
  importer.import
rescue Provider::Sophtron::Error => e
  retry if e.error_type == :fetch_failed && (retries += 1) <= 2 && sleep(2**retries * 10)
  raise # 4xx-shaped fetch_failed means contract change - investigate, don't loop
end

Prevention

When it happens

Trigger: Sophtron API 500/502/503/504 during bank aggregation (upstream bank outages surface as 5xx from Sophtron); gateway timeouts on long-running transaction history fetches; unexpected 422/409 responses from V1 RPC endpoints.

Common situations: A Sophtron or upstream-bank incident; requesting very deep transaction history (MAX_TRANSACTION_HISTORY_YEARS) that times out at the gateway; temporary Cloudflare/proxy 502s between the app and api.sophtron.com.

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/700410061a331982. Report an issue: GitHub.