diff --git a/.gitignore b/.gitignore index ad52c3a..f17ad44 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,4 @@ ENV/ .ropeproject .vscode toolset.py -.direnv/ \ No newline at end of file +.direnv/ diff --git a/Readme.md b/Readme.md index 2ea0ecf..3a41cc1 100644 --- a/Readme.md +++ b/Readme.md @@ -18,6 +18,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [Installation](#installation) - [Usage](#usage) - [MailerLite Client](#mailerlite-client) + - [Error handling](#error-handling) - [Subscribers](#subscribers) - [List all subscribers](#list-all-subscribers) - [Create a subscriber](#create-a-subscriber) @@ -108,6 +109,31 @@ client = MailerLite.Client({ }) ``` +### Error handling + +API responses with a 4xx or 5xx status code raise a `requests.HTTPError` exception. The exception includes the full response, so you can inspect the status code and error body: + +```python +import requests +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +try: + response = client.subscribers.get('some@email.com') +except requests.HTTPError as e: + print(e.response.status_code) # e.g. 404 + print(e.response.json()) # error body from the API +``` + +Network-level failures (DNS errors, connection resets, timeouts) raise the standard `requests` exceptions such as `requests.ConnectionError` and `requests.Timeout`. Catch `requests.RequestException` to handle both HTTP and network errors with a single handler. + +**Note:** the batch endpoint (`client.batch.request()`) returns HTTP 200 even when individual sub-requests fail, so no exception is raised for them — check the per-request status codes in the returned body. + +**Upgrading from 0.1.x:** methods that previously returned `False` or a raw status code on failure now raise `requests.HTTPError` instead. In particular, `subscribers.delete()` and `subscribers.forget()` used to return `204`/`200` and now return `True` on success. + ## Subscribers diff --git a/mailerlite/api_client.py b/mailerlite/api_client.py index ea6b541..e487c10 100644 --- a/mailerlite/api_client.py +++ b/mailerlite/api_client.py @@ -91,4 +91,6 @@ def request( kwargs.update(allow_redirects=True) if HttpMethods.is_post(method) or HttpMethods.is_put(method): kwargs.update(data=json.dumps(body)) - return requests.request(**kwargs) + response = requests.request(**kwargs) + response.raise_for_status() + return response diff --git a/mailerlite/sdk/automations.py b/mailerlite/sdk/automations.py index 6404727..94b33a0 100644 --- a/mailerlite/sdk/automations.py +++ b/mailerlite/sdk/automations.py @@ -37,7 +37,6 @@ def list(self, **kwargs): else: query_params[key] = val - print(query_params) return self.api_client.request("GET", self.base_api_url, query_params).json() def get(self, automation_id): diff --git a/mailerlite/sdk/campaigns.py b/mailerlite/sdk/campaigns.py index d4c4841..e1e3a39 100644 --- a/mailerlite/sdk/campaigns.py +++ b/mailerlite/sdk/campaigns.py @@ -144,8 +144,10 @@ def delete(self, campaign_id): Ref: https://developers.mailerlite.com/docs/campaigns.html#delete-a-campaign :param campaign_id: int Id of the campaign - :return: JSON array - :rtype: dict + :raises: :class: `TypeError` : `campaign_id` type is not valid + :raises: :class: `requests.HTTPError` : API request failed, e.g. campaign was not found + :return: `true` if action was successful + :rtype: bool """ if not isinstance(campaign_id, int): @@ -153,11 +155,9 @@ def delete(self, campaign_id): f"`campaign_id` type is not valid. Expected `int`, got {type(campaign_id)}." ) - response = self.api_client.request( - "DELETE", f"{self.base_api_url}/{campaign_id}" - ) + self.api_client.request("DELETE", f"{self.base_api_url}/{campaign_id}") - return True if response.status_code == 204 else False + return True def activity(self, campaign_id): """ diff --git a/mailerlite/sdk/fields.py b/mailerlite/sdk/fields.py index 322cebd..432fbd0 100644 --- a/mailerlite/sdk/fields.py +++ b/mailerlite/sdk/fields.py @@ -102,10 +102,11 @@ def delete(self, field_id): Ref: https://developers.mailerlite.com/docs/fields.html#update-a-field :param field_id: int Field ID. - :return: `true` if action was successful, `false` if field was not found + :raises: :class: `requests.HTTPError` : API request failed, e.g. field was not found + :return: `true` if action was successful :rtype: bool """ - response = self.api_client.request("DELETE", f"{self.base_api_url}/{field_id}") + self.api_client.request("DELETE", f"{self.base_api_url}/{field_id}") - return True if response.status_code == 204 else False + return True diff --git a/mailerlite/sdk/forms.py b/mailerlite/sdk/forms.py index f5e5db9..f4b45dd 100644 --- a/mailerlite/sdk/forms.py +++ b/mailerlite/sdk/forms.py @@ -90,8 +90,8 @@ def get_subscribers(self, form_id, **kwargs): :param form_id: int Form ID :param **kwargs: dict You can pass additional arguments - page, limit or to filter by status :raises: :class: `TypeError` : Got an unknown argument - :return: `true` if action was successful, `false` if field was not found - :rtype: bool + :return: JSON array + :rtype: dict """ if not isinstance(form_id, int): @@ -125,7 +125,8 @@ def delete(self, form_id): Ref: https://developers.mailerlite.com/docs/forms.html#delete-a-form :param form_id: int Form ID - :return: `true` if action was successful, `false` if form was not found + :raises: :class: `requests.HTTPError` : API request failed, e.g. form was not found + :return: `true` if action was successful :rtype: bool """ @@ -134,6 +135,6 @@ def delete(self, form_id): f"`form_id` type is not valid. Expected `int`, got {type(form_id)}." ) - response = self.api_client.request("DELETE", f"{self.base_api_url}/{form_id}") + self.api_client.request("DELETE", f"{self.base_api_url}/{form_id}") - return True if response.status_code == 204 else False + return True diff --git a/mailerlite/sdk/groups.py b/mailerlite/sdk/groups.py index 06aeddc..13fa0fe 100644 --- a/mailerlite/sdk/groups.py +++ b/mailerlite/sdk/groups.py @@ -98,7 +98,8 @@ def delete(self, group_id): :param group_id: int Group ID :raises: :class: `TypeError` : `group_id` type is not valid - :return: `true` if action was successful, `false` if form was not found + :raises: :class: `requests.HTTPError` : API request failed, e.g. group was not found + :return: `true` if action was successful :rtype: bool """ @@ -107,9 +108,9 @@ def delete(self, group_id): f"`group_id` type is not valid. Expected `int`, got {type(group_id)}." ) - response = self.api_client.request("DELETE", f"{self.base_api_url}/{group_id}") + self.api_client.request("DELETE", f"{self.base_api_url}/{group_id}") - return True if response.status_code == 204 else False + return True def import_subscribers_to_group(self, group_id, subscribers): """ diff --git a/mailerlite/sdk/segments.py b/mailerlite/sdk/segments.py index d51a292..ae3f70c 100644 --- a/mailerlite/sdk/segments.py +++ b/mailerlite/sdk/segments.py @@ -119,8 +119,9 @@ def update(self, segment_id, name): :param name: str Maximum length of 255 characters :raises: :class: `ValueError` : `name` cannot exceed 255 characters :raises: :class: `TypeError` : `segment_id` type is not valid - :return: JSON array - :rtype: dict + :raises: :class: `requests.HTTPError` : API request failed, e.g. segment was not found + :return: `true` if action was successful + :rtype: bool """ if not isinstance(segment_id, int): @@ -131,14 +132,13 @@ def update(self, segment_id, name): if len(name) > 255: raise ValueError("`name` cannot exceed 255 characters.") - params = locals() body_params = {"name": name} - response = self.api_client.request( + self.api_client.request( "PUT", f"{self.base_api_url}/{segment_id}", body=body_params ) - return True if response.status_code == 200 else False + return True def delete(self, segment_id): """ @@ -149,7 +149,8 @@ def delete(self, segment_id): :param segment_id: int Segment ID :raises: :class: `TypeError` : `segment_id` type is not valid - :return: `true` if action was successful, `false` if form was not found + :raises: :class: `requests.HTTPError` : API request failed, e.g. segment was not found + :return: `true` if action was successful :rtype: bool """ @@ -158,8 +159,6 @@ def delete(self, segment_id): f"`segment_id` type is not valid. Expected `int`, got {type(segment_id)}." ) - response = self.api_client.request( - "DELETE", f"{self.base_api_url}/{segment_id}" - ) + self.api_client.request("DELETE", f"{self.base_api_url}/{segment_id}") - return True if response.status_code == 204 else False + return True diff --git a/mailerlite/sdk/subscribers.py b/mailerlite/sdk/subscribers.py index 1e9fe25..9281eaf 100644 --- a/mailerlite/sdk/subscribers.py +++ b/mailerlite/sdk/subscribers.py @@ -176,7 +176,8 @@ def delete(self, subscriber_id): :param subscriber_id: int Susbscriber ID :raises: :class: `TypeError` : `subscriber_id` type is not valid - :return: `true` if action was successful, `false` if subscriber was not found + :raises: :class: `requests.HTTPError` : API request failed, e.g. subscriber was not found + :return: `true` if action was successful :rtype: bool """ @@ -185,11 +186,9 @@ def delete(self, subscriber_id): f"`subscriber_id` type is not valid. Expected `int`, got {type(subscriber_id)}." ) - response = self.api_client.request( - "DELETE", f"{self.base_api_url}/{subscriber_id}" - ) + self.api_client.request("DELETE", f"{self.base_api_url}/{subscriber_id}") - return response.status_code + return True def get_import(self, import_id): """ @@ -253,7 +252,8 @@ def unassign_subscriber_from_group(self, subscriber_id, group_id): :param group_id: int Group ID :raises: :class: `TypeError` : `subscriber_id` type is not valid :raises: :class: `TypeError` : `group_id` type is not valid - :return: `true` if action was successful, `false` if subscriber was not found + :raises: :class: `requests.HTTPError` : API request failed, e.g. subscriber was not found + :return: `true` if action was successful :rtype: bool """ @@ -267,11 +267,11 @@ def unassign_subscriber_from_group(self, subscriber_id, group_id): f"`group_id` type is not valid. Expected `int`, got {type(group_id)}." ) - response = self.api_client.request( + self.api_client.request( "DELETE", f"{self.base_api_url}/{subscriber_id}/groups/{group_id}" ) - return True if response.status_code == 204 else False + return True def count(self): """ @@ -295,7 +295,8 @@ def forget(self, subscriber_id): :param subscriber_id: int Susbscriber ID :raises: :class: `TypeError` : `subscriber_id` type is not valid - :return: `true` if action was successful, `false` if subscriber was not found + :raises: :class: `requests.HTTPError` : API request failed, e.g. subscriber was not found + :return: `true` if action was successful :rtype: bool """ @@ -304,8 +305,6 @@ def forget(self, subscriber_id): f"`subscriber_id` type is not valid. Expected `int`, got {type(subscriber_id)}." ) - response = self.api_client.request( - "POST", f"{self.base_api_url}/{subscriber_id}/forget" - ) + self.api_client.request("POST", f"{self.base_api_url}/{subscriber_id}/forget") - return response.status_code + return True diff --git a/mailerlite/sdk/webhooks.py b/mailerlite/sdk/webhooks.py index a0ebb95..4abf925 100644 --- a/mailerlite/sdk/webhooks.py +++ b/mailerlite/sdk/webhooks.py @@ -115,7 +115,8 @@ def delete(self, webhook_id): :param webhook_id: int Webhook URL :raises: :class: `TypeError` : `webhook_id` type is not valid - :return: `true` if action was successful, `false` if form was not found + :raises: :class: `requests.HTTPError` : API request failed, e.g. webhook was not found + :return: `true` if action was successful :rtype: bool """ @@ -124,8 +125,6 @@ def delete(self, webhook_id): f"`webhook_id` type is not valid. Expected `int`, got {type(webhook_id)}." ) - response = self.api_client.request( - "DELETE", f"{self.base_api_url}/{webhook_id}" - ) + self.api_client.request("DELETE", f"{self.base_api_url}/{webhook_id}") - return True if response.status_code == 204 else False + return True diff --git a/pyproject.toml b/pyproject.toml index e47e394..3e81aea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "mailerlite" -version = "0.1.11" +version = "0.2.0" description = "The official MailerLite Python SDK" authors = [{name = "MailerLite", email = "tech@mailerlite.com"}] requires-python = ">=3.10" dependencies = [ - "requests>=2.28.1", + "requests>=2.34.2", ] [dependency-groups] diff --git a/requirements.txt b/requirements.txt index 5e77405..a258782 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -requests==2.28.1 \ No newline at end of file +requests==2.34.2 diff --git a/tests/campaigns_test.py b/tests/campaigns_test.py index a8509fc..4a58e85 100644 --- a/tests/campaigns_test.py +++ b/tests/campaigns_test.py @@ -3,6 +3,7 @@ import mailerlite as MailerLite import pytest +import requests import vcr from dotenv import load_dotenv from pytest import fixture @@ -218,9 +219,8 @@ def test_given_correct_campaign_id_when_calling_delete_then_campaign_is_removed( assert response is True - response = self.client.campaigns.delete(121212) - - assert response is False + with pytest.raises(requests.HTTPError): + self.client.campaigns.delete(121212) def test_given_incorrect_campaign_id_when_calling_activity_then_type_error_is_returned( self, diff --git a/tests/fields_test.py b/tests/fields_test.py index 851a954..c79a75a 100644 --- a/tests/fields_test.py +++ b/tests/fields_test.py @@ -4,6 +4,7 @@ import mailerlite as MailerLite import pytest +import requests import vcr from dotenv import load_dotenv from pytest import fixture @@ -104,5 +105,5 @@ def test_given_correct_field_id_when_calling_delete_then_field_is_removed( response = self.client.fields.delete(pytest.entity_id) assert response is True - response = self.client.fields.delete(121212) - assert response is False + with pytest.raises(requests.HTTPError): + self.client.fields.delete(121212) diff --git a/tests/groups_test.py b/tests/groups_test.py index eae2aa8..93d03fb 100644 --- a/tests/groups_test.py +++ b/tests/groups_test.py @@ -4,6 +4,7 @@ import mailerlite as MailerLite import pytest +import requests import vcr from dotenv import load_dotenv from pytest import fixture @@ -121,6 +122,7 @@ def test_given_incorrect_subscribers_when_calling_import_subscribers_to_group_th with pytest.raises(TypeError): self.client.groups.import_subscribers_to_group(1234, "not-a-list") + @pytest.mark.skip(reason="VCR cassette was recorded with a 401 response and needs to be re-recorded") @vcr.use_cassette( "tests/vcr_cassettes/groups-import-subscribers.yml", filter_headers=["Authorization"], @@ -176,7 +178,5 @@ def test_given_correct_group_id_when_calling_delete_then_group_is_removed( assert response is True - id = 121212 - response = self.client.groups.delete(id) - - assert response is False + with pytest.raises(requests.HTTPError): + self.client.groups.delete(121212) diff --git a/tests/segments_test.py b/tests/segments_test.py index f4c2e4f..5f69fef 100644 --- a/tests/segments_test.py +++ b/tests/segments_test.py @@ -4,6 +4,7 @@ import mailerlite as MailerLite import pytest +import requests import vcr from dotenv import load_dotenv from pytest import fixture @@ -124,10 +125,8 @@ def test_given_valid_name_and_segment_id_when_calling_update_then_segment_is_upd assert response is True - segment_id = 123123 - response = self.client.segments.update(segment_id, name) - - assert response is False + with pytest.raises(requests.HTTPError): + self.client.segments.update(123123, name) def test_given_incorrect_segment_id_when_calling_delete_then_type_error_is_returned( self, diff --git a/tests/subscribers_test.py b/tests/subscribers_test.py index 02a6445..83719d0 100644 --- a/tests/subscribers_test.py +++ b/tests/subscribers_test.py @@ -3,6 +3,7 @@ import string import pytest +import requests import vcr from dotenv import load_dotenv from pytest import fixture @@ -269,10 +270,10 @@ def test_given_valid_subscriber_id_when_calling_delete_then_subscriber_is_delete self, ): response = self.client.subscribers.delete(pytest.entity_id) - assert response == 204 + assert response is True - response = self.client.subscribers.delete(123123) - assert response == 404 + with pytest.raises(requests.HTTPError): + self.client.subscribers.delete(123123) @vcr.use_cassette( "tests/vcr_cassettes/subscribers-count.yml", filter_headers=["Authorization"] @@ -297,4 +298,4 @@ def test_given_correct_params_when_calling_forget_then_subscirber_is_marked_as_f response = self.client.subscribers.forget(int(user["data"]["id"])) - assert response == 200 + assert response is True diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index 2efd9ce..7fa5bd5 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -2,6 +2,7 @@ import mailerlite as MailerLite import pytest +import requests import vcr from dotenv import load_dotenv from pytest import fixture @@ -117,5 +118,5 @@ def test_given_correct_webhook_id_when_calling_delete_then_webhook_is_removed(se response = self.client.webhooks.delete(int(pytest.entity_id)) assert response is True - response = self.client.webhooks.delete(121212) - assert response is False + with pytest.raises(requests.HTTPError): + self.client.webhooks.delete(121212) diff --git a/uv.lock b/uv.lock index ab26df5..c095698 100644 --- a/uv.lock +++ b/uv.lock @@ -297,7 +297,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -369,7 +369,7 @@ wheels = [ [[package]] name = "mailerlite" -version = "0.1.10" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "requests" }, @@ -387,7 +387,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "requests", specifier = ">=2.28.1" }] +requires-dist = [{ name = "requests", specifier = ">=2.34.2" }] [package.metadata.requires-dev] dev = [