From e97dec73068199a280dfa566500a3cf7b391020a Mon Sep 17 00:00:00 2001 From: codl Date: Wed, 3 Jan 2018 11:34:45 +0100 Subject: [PATCH 1/2] subclass api errors --- mastodon/Mastodon.py | 74 +++++++++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index 28ad9d8..5aca185 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py @@ -1550,33 +1550,45 @@ class Mastodon: print('response headers: ' + str(response_object.headers)) print('Response text content: ' + str(response_object.text)) - if response_object.status_code == 404: + if not response_object.ok: try: - response = response_object.json() - except: - raise MastodonAPIError('Endpoint not found.') + response = response_object.json(object_hook=self.__json_hooks) + if not isinstance(response, dict) or 'error' not in response: + error_msg = None + error_msg = response['error'] + except ValueError: + error_msg = None - if isinstance(response, dict) and 'error' in response: - raise MastodonAPIError("Mastodon API returned error: " + str(response['error'])) + # Handle rate limiting + if response_object.status_code == 429: + if self.ratelimit_method == 'throw' or not do_ratelimiting: + raise MastodonRatelimitError('Hit rate limit.') + elif self.ratelimit_method in ('wait', 'pace'): + to_next = self.ratelimit_reset - time.time() + if to_next > 0: + # As a precaution, never sleep longer than 5 minutes + to_next = min(to_next, 5 * 60) + time.sleep(to_next) + request_complete = False + continue + + if response_object.status_code == 404: + ex_type = MastodonNotFoundError + if not error_msg: + error_msg = 'Endpoint not found.' + # this is for compatibility with older versions + # which raised MastodonAPIError('Endpoint not found.') + # on any 404 + elif response_object.status_code == 401: + ex_type = MastodonUnauthorizedError else: - raise MastodonAPIError('Endpoint not found.') + ex_type = MastodonAPIError - - if response_object.status_code == 500: - raise MastodonAPIError('General API problem.') - - # Handle rate limiting - if response_object.status_code == 429: - if self.ratelimit_method == 'throw' or not do_ratelimiting: - raise MastodonRatelimitError('Hit rate limit.') - elif self.ratelimit_method in ('wait', 'pace'): - to_next = self.ratelimit_reset - time.time() - if to_next > 0: - # As a precaution, never sleep longer than 5 minutes - to_next = min(to_next, 5 * 60) - time.sleep(to_next) - request_complete = False - continue + raise ex_type( + 'Mastodon API returned error', + response_object.status_code, + response_object.reason, + error_msg) try: response = response_object.json(object_hook=self.__json_hooks) @@ -1586,12 +1598,6 @@ class Mastodon: "bad json content was '%s'" % (response_object.status_code, response_object.content)) - # See if the returned dict is an error dict even though status is 200 - if isinstance(response, dict) and 'error' in response: - if not isinstance(response['error'], six.string_types): - response['error'] = six.text_type(response['error']) - raise MastodonAPIError("Mastodon API returned error: " + response['error']) - # Parse link headers if isinstance(response, list) and \ 'Link' in response_object.headers and \ @@ -1801,6 +1807,16 @@ class MastodonAPIError(MastodonError): """Raised when the mastodon API generates a response that cannot be handled""" pass +class MastodonNotFoundError(MastodonAPIError): + """Raised when the mastodon API returns a 404 Not Found error""" + pass + +class MastodonUnauthorizedError(MastodonAPIError): + """Raised when the mastodon API returns a 401 Unauthorized error + + This happens when an OAuth token is invalid or has been revoked.""" + pass + class MastodonRatelimitError(MastodonError): """Raised when rate limiting is set to manual mode and the rate limit is exceeded""" From 56e6bac9cb19101346cb42b73628290740889ecf Mon Sep 17 00:00:00 2001 From: codl Date: Wed, 3 Jan 2018 11:44:14 +0100 Subject: [PATCH 2/2] update tests --- .../test_unauthed_home_tl_throws.yaml | 82 +++++++++++++++++++ tests/test_status.py | 5 +- tests/test_timeline.py | 9 +- 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 tests/cassettes/test_unauthed_home_tl_throws.yaml diff --git a/tests/cassettes/test_unauthed_home_tl_throws.yaml b/tests/cassettes/test_unauthed_home_tl_throws.yaml new file mode 100644 index 0000000..b63d840 --- /dev/null +++ b/tests/cassettes/test_unauthed_home_tl_throws.yaml @@ -0,0 +1,82 @@ +interactions: +- request: + body: visibility=&status=Toot%21 + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Authorization: [Bearer __MASTODON_PY_TEST_ACCESS_TOKEN] + Connection: [keep-alive] + Content-Length: ['26'] + Content-Type: [application/x-www-form-urlencoded] + User-Agent: [python-requests/2.18.4] + method: POST + uri: http://localhost:3000/api/v1/statuses + response: + body: {string: '{"id":"99285482671609362","created_at":"2018-01-03T10:43:57.160Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"private","language":"ja","uri":"http://localhost:3000/users/mastodonpy_test/statuses/99285482671609362","content":"\u003cp\u003eToot!\u003c/p\u003e","url":"http://localhost:3000/@mastodonpy_test/99285482671609362","reblogs_count":0,"favourites_count":0,"favourited":false,"reblogged":false,"muted":false,"reblog":null,"application":{"name":"Mastodon.py + test suite","website":null},"account":{"id":"1234567890123456","username":"mastodonpy_test","acct":"mastodonpy_test","display_name":"","locked":true,"created_at":"2018-01-03T11:24:32.957Z","note":"\u003cp\u003e\u003c/p\u003e","url":"http://localhost:3000/@mastodonpy_test","avatar":"http://localhost:3000/avatars/original/missing.png","avatar_static":"http://localhost:3000/avatars/original/missing.png","header":"http://localhost:3000/headers/original/missing.png","header_static":"http://localhost:3000/headers/original/missing.png","followers_count":0,"following_count":0,"statuses_count":1},"media_attachments":[],"mentions":[],"tags":[],"emojis":[]}'} + headers: + Cache-Control: ['max-age=0, private, must-revalidate'] + Content-Type: [application/json; charset=utf-8] + ETag: [W/"d9b57bb0592371b00e98fbc0f44a8fc9"] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Content-Type-Options: [nosniff] + X-Frame-Options: [SAMEORIGIN] + X-Request-Id: [d7a9df07-1a3c-4784-adc5-b67bd6347614] + X-Runtime: ['0.301984'] + X-XSS-Protection: [1; mode=block] + content-length: ['1175'] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.18.4] + method: GET + uri: http://localhost:3000/api/v1/timelines/home + response: + body: {string: '{"error":"The access token is invalid"}'} + headers: + Cache-Control: [no-store] + Content-Type: [application/json; charset=utf-8] + Pragma: [no-cache] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + WWW-Authenticate: ['Bearer realm="Doorkeeper", error="invalid_token", error_description="The + access token is invalid"'] + X-Content-Type-Options: [nosniff] + X-Frame-Options: [SAMEORIGIN] + X-Request-Id: [dc45d4f4-c203-4b28-ad27-f0db32912a16] + X-Runtime: ['0.010224'] + X-XSS-Protection: [1; mode=block] + content-length: ['39'] + status: {code: 401, message: Unauthorized} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Authorization: [Bearer __MASTODON_PY_TEST_ACCESS_TOKEN] + Connection: [keep-alive] + Content-Length: ['0'] + User-Agent: [python-requests/2.18.4] + method: DELETE + uri: http://localhost:3000/api/v1/statuses/99285482671609362 + response: + body: {string: '{}'} + headers: + Cache-Control: ['max-age=0, private, must-revalidate'] + Content-Type: [application/json; charset=utf-8] + ETag: [W/"8ca371aea536ee2c56c8d13b43824703"] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Content-Type-Options: [nosniff] + X-Frame-Options: [SAMEORIGIN] + X-Request-Id: [ddbd4335-1aeb-42af-8dea-fa78a787609f] + X-Runtime: ['0.017701'] + X-XSS-Protection: [1; mode=block] + content-length: ['2'] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/test_status.py b/tests/test_status.py index b177517..2e129ac 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,6 +1,5 @@ import pytest -from mastodon.Mastodon import MastodonAPIError -from time import sleep +from mastodon.Mastodon import MastodonAPIError, MastodonNotFoundError @pytest.mark.vcr() def test_status(status, api): @@ -14,7 +13,7 @@ def test_status_empty(api): @pytest.mark.vcr() def test_status_missing(api): - with pytest.raises(MastodonAPIError): + with pytest.raises(MastodonNotFoundError): api.status(0) @pytest.mark.skip(reason="Doesn't look like mastodon will make a card for an url that doesn't have a TLD, and relying on some external website being reachable to make a card of is messy :/") diff --git a/tests/test_timeline.py b/tests/test_timeline.py index 6a27be3..5108b63 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -1,5 +1,7 @@ import pytest -from mastodon.Mastodon import MastodonAPIError, MastodonIllegalArgumentError +from mastodon.Mastodon import MastodonAPIError,\ + MastodonIllegalArgumentError,\ + MastodonUnauthorizedError @pytest.mark.vcr() def test_public_tl_anonymous(api_anonymous, status): @@ -17,6 +19,11 @@ def test_public_tl(api, status): assert status['id'] in map(lambda st: st['id'], public) assert status['id'] in map(lambda st: st['id'], local) +@pytest.mark.vcr() +def test_unauthed_home_tl_throws(api_anonymous, status): + with pytest.raises(MastodonUnauthorizedError): + api_anonymous.timeline_home() + @pytest.mark.vcr() def test_home_tl(api, status): tl = api.timeline_home()