{"record":{"id":"cff48f87c1a512fb","repo":"we-promise/sure","slug":"invalid-response-cff48f","errorCode":"invalid_response","errorMessage":"Invalid Sophtron response format","messagePattern":"Invalid Sophtron response format","errorType":"exception","errorClass":"Provider::Sophtron::Error","httpStatus":null,"severity":"error","filePath":"app/models/provider/sophtron.rb","lineNumber":323,"sourceCode":"        :post,\n        \"/UserInstitution/GetUserInstitutionAccounts\",\n        body: { UserInstitutionID: user_institution_id }\n      )\n      extract_array_response(parsed, :accounts, :Accounts)\n    end\n\n    def extract_array_response(parsed, *keys)\n      return parsed if parsed.is_a?(Array)\n      return [] if parsed.respond_to?(:empty?) && parsed.empty?\n\n      if parsed.respond_to?(:with_indifferent_access)\n        parsed = parsed.with_indifferent_access\n        keys.each do |key|\n          return Array(parsed[key]) if parsed.key?(key)\n        end\n      end\n\n      raise Error.new(\"Invalid Sophtron response format\", :invalid_response, details: parsed)\n    end\n\n    def request(method, api_path, body: nil, parse_json: true)\n      options = { headers: auth_headers(method: method, api_path: api_path) }\n      options[:body] = body.to_json if body\n\n      response = self.class.public_send(method, \"#{base_url}#{api_path}\", options)\n      handle_response(response, parse_json: parse_json)\n    rescue Error\n      raise\n    rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e\n      raise Error.new(\"Sophtron request failed: #{e.message}\", :request_failed)\n    rescue StandardError => e\n      raise Error.new(\"Sophtron request failed: #{e.message}\", :request_failed)\n    end\n\n    def auth_headers(method:, api_path:)\n      {","sourceCodeStart":305,"sourceCodeEnd":341,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/sophtron.rb#L305-L341","documentation":"Raised by Provider::Sophtron's extract_array_response helper when the parsed response is neither an Array nor a Hash containing any of the expected keys (it normalizes with with_indifferent_access and looks up each candidate key). It is a shape guard: Sophtron returned 2xx JSON, but in a form the caller's key list does not cover - typically a schema change or an error object wearing a success status. The full parsed payload is attached as details.","triggerScenarios":"A Sophtron endpoint normally returning {\"jobs\": [...]} or {\"data\": [...]} instead returns {\"error\": ...}, an envelope with a new key name after an API update, or a nested structure the key list was never taught (e.g. institution-specific responses).","commonSituations":"Sophtron ships an API revision renaming response fields; a middleware returns a JSON error object with HTTP 200; a new institution's data shape differs from existing ones; the key list wasn't extended when a new endpoint was wired through this helper.","solutions":["Inspect err details / logs - the parsed payload shows exactly which keys arrived; if one is 'error', chase the upstream message","Reproduce the raw call with curl using the same auth header to see the true envelope","If Sophtron renamed the field, add the new key to the *keys argument of the extract_array_response call site","Keep the previous data snapshot on this failure instead of treating it as an empty result"],"exampleFix":"# before\narr = extract_array_response(parsed, :jobs)\n\n# after - accept the renamed envelope from a newer Sophtron API\narr = extract_array_response(parsed, :jobs, :data, :results)","handlingStrategy":"type-guard","validationCode":null,"typeGuard":"def sophtron_array_envelope?(parsed, *keys)\n  return true if parsed.is_a?(Array)\n  return false unless parsed.is_a?(Hash)\n  keys.any? { |k| parsed.key?(k.to_s) || parsed.key?(k.to_sym) }\nend","tryCatchPattern":"begin\n  items = client.get_institutions # passes through extract_array_response\nrescue Provider::Sophtron::Error => e\n  if e.error_type == :invalid_response\n    log_payload_shape(e.message) # details holds the parsed payload\n    keep_previous_data # never downgrade to empty\n  else\n    raise\n  end\nend","preventionTips":["Never convert this error into an empty array - it means the shape was unexpected, not that data is empty","Keep VCR/recorded cassettes of each Sophtron endpoint envelope so schema changes break tests first","Extend the key list in extract_array_response call sites when adopting new endpoints"],"tags":["sophtron","schema-change","response-shape","type-guard"],"backgroundTag":"api-response-schema-changed","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}