From 1857c04f6069231ded4e06d6a737e1699878ab0a Mon Sep 17 00:00:00 2001 From: Craig Phares Date: Tue, 21 Jul 2026 11:35:43 -0400 Subject: [PATCH] Reclassify OAuthException code 190 as Unauthorized Meta returns expired/invalid/revoked access-token errors as an OAuthException with code 190, frequently over HTTP 400 rather than 401, and the middleware mapped purely by HTTP status. Callers could not tell an expired token apart from a genuine bad request (both HTTP 400). - Add an Unauthorized error class (there was none). - Give Error an initializer carrying http_status/payload, plus #code / #error_subcode readers (supports nested {"error":{"code"}} and flat {"code"} envelopes). - Middleware now raises Unauthorized whenever the payload carries code 190, and adds a 401 -> Unauthorized case. Non-token 400s still raise BadRequest. Bump to 0.0.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- Gemfile.lock | 2 +- instagram_basic_display_api.gemspec | 2 +- lib/instagram_basic_display_api/error.rb | 33 +++++++- .../raise_http_exception.rb | 82 +++++++++++++++---- spec/raise_http_exception_spec.rb | 55 +++++++++++++ 5 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 spec/raise_http_exception_spec.rb 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