Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
2 changes: 1 addition & 1 deletion instagram_basic_display_api.gemspec
Original file line number Diff line number Diff line change
@@ -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}/**/*']
Expand Down
33 changes: 32 additions & 1 deletion lib/instagram_basic_display_api/error.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down
82 changes: 64 additions & 18 deletions lib/instagram_basic_display_api/raise_http_exception.rb
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions spec/raise_http_exception_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading