diff --git a/Gemfile.lock b/Gemfile.lock index 9a9798b..3999b19 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - instagram_basic_display_api (0.0.3) + instagram_basic_display_api (0.0.4) faraday (>= 0.17.3, < 2.8.0) faraday_middleware (>= 0.14, < 1.3) diff --git a/instagram_basic_display_api.gemspec b/instagram_basic_display_api.gemspec index 97404c7..606fcbd 100644 --- a/instagram_basic_display_api.gemspec +++ b/instagram_basic_display_api.gemspec @@ -1,6 +1,6 @@ Gem::Specification.new do |s| s.name = 'instagram_basic_display_api' - s.version = '0.0.3' + s.version = '0.0.4' s.date = '2024-12-05' s.summary = 'Instagram Basic Display API' s.files = Dir['{lib,spec}/**/*'] diff --git a/lib/instagram_basic_display_api/error.rb b/lib/instagram_basic_display_api/error.rb index 6af6e7c..9e1a457 100644 --- a/lib/instagram_basic_display_api/error.rb +++ b/lib/instagram_basic_display_api/error.rb @@ -1,10 +1,41 @@ module InstagramBasicDisplayAPI # Custom error class for rescuing from all Instagram errors - class Error < StandardError; end + class Error < StandardError + attr_reader :http_status, :payload + + def initialize(message = nil, http_status: nil, payload: nil) + super(message) + @http_status = http_status + @payload = payload + end + + # Meta error code (e.g. 190 = token expired/invalid, 100 = object missing). + # nil when the response carried no structured error body. + def code + hash = error_hash + return hash['code'] if hash.key?('code') + + payload.is_a?(Hash) ? payload['code'] : nil + end + + def error_subcode + error_hash['error_subcode'] + end + + private + + def error_hash + payload.is_a?(Hash) && payload['error'].is_a?(Hash) ? payload['error'] : {} + end + end # Raised when Instagram returns the HTTP status code 400 class BadRequest < Error; end + # Raised when Instagram returns an expired/invalid access token (OAuthException + # code 190), which Meta often delivers over HTTP 400, or over HTTP 401. + class Unauthorized < Error; end + # Raised when Instagram returns the HTTP status code 403 class Forbidden < Error; end diff --git a/lib/instagram_basic_display_api/raise_http_exception.rb b/lib/instagram_basic_display_api/raise_http_exception.rb index a17c93c..64822cf 100644 --- a/lib/instagram_basic_display_api/raise_http_exception.rb +++ b/lib/instagram_basic_display_api/raise_http_exception.rb @@ -1,29 +1,26 @@ require 'faraday' +require 'json' # @private module InstagramBasicDisplayAPI # @private class RaiseHttpException < Faraday::Middleware + # Meta returns expired / invalid / revoked access-token errors as an + # OAuthException with code 190, frequently over HTTP 400 rather than 401. + # We surface those as Unauthorized so callers can rescue the correct class + # regardless of Meta's inconsistent status code. + OAUTH_ACCESS_TOKEN_ERROR_CODE = 190 + def call(env) @app.call(env).on_complete do |response| - case response[:status].to_i - when 400 - raise InstagramBasicDisplayAPI::BadRequest, error_message_400(response) - when 403 - raise InstagramBasicDisplayAPI::Forbidden, error_message_400(response) - when 404 - raise InstagramBasicDisplayAPI::NotFound, error_message_400(response) - when 429 - raise InstagramBasicDisplayAPI::TooManyRequests, error_message_400(response) - when 500 - raise InstagramBasicDisplayAPI::InternalServerError, error_message_500(response, "Something is technically wrong.") - when 502 - raise InstagramBasicDisplayAPI::BadGateway, error_message_500(response, "The server returned an invalid or incomplete response.") - when 503 - raise InstagramBasicDisplayAPI::ServiceUnavailable, error_message_500(response, "Instagram is rate limiting your requests.") - when 504 - raise InstagramBasicDisplayAPI::GatewayTimeout, error_message_500(response, "504 Gateway Time-out") - end + status = response[:status].to_i + next if status < 400 + + payload = parse_payload(response[:body]) + klass = exception_class(status, payload) + next unless klass + + raise klass.new(error_message(response, status), http_status: status, payload: payload) end end @@ -34,6 +31,55 @@ def initialize(app) private + def exception_class(status, payload) + return InstagramBasicDisplayAPI::Unauthorized if access_token_error?(payload) + + case status + when 400 then InstagramBasicDisplayAPI::BadRequest + when 401 then InstagramBasicDisplayAPI::Unauthorized + when 403 then InstagramBasicDisplayAPI::Forbidden + when 404 then InstagramBasicDisplayAPI::NotFound + when 429 then InstagramBasicDisplayAPI::TooManyRequests + when 500 then InstagramBasicDisplayAPI::InternalServerError + when 502 then InstagramBasicDisplayAPI::BadGateway + when 503 then InstagramBasicDisplayAPI::ServiceUnavailable + when 504 then InstagramBasicDisplayAPI::GatewayTimeout + end + end + + # True when the response carries an OAuthException access-token error + # (code 190), whatever the HTTP status. Supports both the nested + # `{ "error": { "code": 190 } }` envelope and a flat `{ "code": 190 }`. + def access_token_error?(payload) + extract_code(payload) == OAUTH_ACCESS_TOKEN_ERROR_CODE + end + + def extract_code(payload) + return nil unless payload.is_a?(Hash) + + err = payload['error'] + (err.is_a?(Hash) && err['code']) || payload['code'] + end + + def parse_payload(body) + return body if body.is_a?(Hash) + return {} unless body.is_a?(String) && !body.empty? + + JSON.parse(body) + rescue JSON::ParserError + {} + end + + def error_message(response, status) + case status + when 500 then error_message_500(response, 'Something is technically wrong.') + when 502 then error_message_500(response, 'The server returned an invalid or incomplete response.') + when 503 then error_message_500(response, 'Instagram is rate limiting your requests.') + when 504 then error_message_500(response, '504 Gateway Time-out') + else error_message_400(response) + end + end + def error_message_400(response) "#{response[:method].to_s.upcase} #{response[:url].to_s}: #{response[:status]}#{error_body(response[:body])}" end diff --git a/spec/raise_http_exception_spec.rb b/spec/raise_http_exception_spec.rb new file mode 100644 index 0000000..6308a94 --- /dev/null +++ b/spec/raise_http_exception_spec.rb @@ -0,0 +1,55 @@ +require File.expand_path('../../lib/instagram_basic_display_api', __FILE__) +require 'faraday' +require 'json' + +describe InstagramBasicDisplayAPI::RaiseHttpException do + def get_with(status, body) + conn = Faraday.new do |f| + f.use InstagramBasicDisplayAPI::RaiseHttpException + f.adapter :test do |stub| + stub.get('/x') { [status, { 'Content-Type' => 'application/json' }, body] } + end + end + conn.get('/x') + end + + def oauth_190 + JSON.dump('error' => { 'message' => 'The access token is invalid', 'type' => 'OAuthException', 'code' => 190 }) + end + + def object_missing_100 + JSON.dump('error' => { 'message' => 'Object does not exist', 'type' => 'IGApiException', 'code' => 100 }) + end + + it 'raises Unauthorized for an OAuthException code 190 returned over HTTP 400' do + expect { get_with(400, oauth_190) }.to raise_error(InstagramBasicDisplayAPI::Unauthorized) do |err| + expect(err.http_status).to eq(400) + expect(err.code).to eq(190) + end + end + + it 'raises Unauthorized for a plain HTTP 401' do + expect { get_with(401, JSON.dump('error' => { 'message' => 'nope', 'code' => 1 })) } + .to raise_error(InstagramBasicDisplayAPI::Unauthorized) + end + + it 'raises BadRequest for a non-token HTTP 400 (code 100 object missing)' do + expect { get_with(400, object_missing_100) }.to raise_error(InstagramBasicDisplayAPI::BadRequest) do |err| + expect(err.code).to eq(100) + end + end + + it 'also detects a flat { "code": 190 } envelope' do + expect { get_with(400, JSON.dump('code' => 190, 'error_message' => 'invalid token')) } + .to raise_error(InstagramBasicDisplayAPI::Unauthorized) + end + + it 'raises the status-mapped class for other errors' do + expect { get_with(429, '{}') }.to raise_error(InstagramBasicDisplayAPI::TooManyRequests) + expect { get_with(500, '{}') }.to raise_error(InstagramBasicDisplayAPI::InternalServerError) + end + + it 'does not raise on a success' do + expect { get_with(200, '{"data":[]}') }.not_to raise_error + end +end