diff --git a/tap_facebook/__init__.py b/tap_facebook/__init__.py index c3224410..b9265d37 100755 --- a/tap_facebook/__init__.py +++ b/tap_facebook/__init__.py @@ -145,7 +145,7 @@ def log_retry_attempt(details): if isinstance(exception, TypeError) and str(exception) == "string indices must be integers": LOGGER.info('TypeError due to bad JSON response') def should_retry_api_error(exception): - if isinstance(exception, FacebookBadObjectError): + if isinstance(exception, FacebookBadObjectError) or isinstance(exception, AttributeError): return True elif isinstance(exception, FacebookRequestError): return (exception.api_transient_error() @@ -248,6 +248,8 @@ class AdCreative(Stream): doc: https://developers.facebook.com/docs/marketing-api/reference/adgroup/adcreatives/ ''' + # Added retry_pattern to handle AttributeError raised from api_batch.execute() below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def sync_batches(self, stream_objects): refs = load_shared_schema_refs() schema = singer.resolve_schema_references(self.catalog_entry.schema.to_dict(), refs) @@ -276,7 +278,8 @@ def sync_batches(self, stream_objects): key_properties = ['id'] - @retry_pattern(backoff.expo, (FacebookRequestError, TypeError), max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from account.get_ad_creatives() below + @retry_pattern(backoff.expo, (FacebookRequestError, TypeError, AttributeError), max_tries=5, factor=5) def get_adcreatives(self): return self.account.get_ad_creatives(params={'limit': RESULT_RETURN_LIMIT}) @@ -292,7 +295,8 @@ class Ads(IncrementalStream): key_properties = ['id', 'updated_time'] - @retry_pattern(backoff.expo, FacebookRequestError, max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from account.get_ads() below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def _call_get_ads(self, params): """ This is necessary because the functions that call this endpoint return @@ -317,7 +321,8 @@ def do_request_multiple(): filt_ads = self._call_get_ads(params) yield filt_ads - @retry_pattern(backoff.expo, FacebookRequestError, max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from ad.api_get() below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def prepare_record(ad): return ad.api_get(fields=self.fields()).export_all_data() @@ -336,7 +341,8 @@ class AdSets(IncrementalStream): key_properties = ['id', 'updated_time'] - @retry_pattern(backoff.expo, FacebookRequestError, max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from account.get_ad_sets() below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def _call_get_ad_sets(self, params): """ This is necessary because the functions that call this endpoint return @@ -361,7 +367,8 @@ def do_request_multiple(): filt_adsets = self._call_get_ad_sets(params) yield filt_adsets - @retry_pattern(backoff.expo, FacebookRequestError, max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from ad_set.api_get() below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def prepare_record(ad_set): return ad_set.api_get(fields=self.fields()).export_all_data() @@ -377,7 +384,8 @@ class Campaigns(IncrementalStream): key_properties = ['id'] - @retry_pattern(backoff.expo, FacebookRequestError, max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from account.get_campaigns() below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def _call_get_campaigns(self, params): """ This is necessary because the functions that call this endpoint return @@ -407,7 +415,8 @@ def do_request_multiple(): filt_campaigns = self._call_get_campaigns(params) yield filt_campaigns - @retry_pattern(backoff.expo, FacebookRequestError, max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from request call below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def prepare_record(campaign): """If campaign.ads is selected, make the request and insert the data here""" campaign_out = campaign.api_get(fields=fields).export_all_data() @@ -444,6 +453,8 @@ def compare_lead_created_times(self, leadA, leadB): else: return leadA + # Added retry_pattern to handle AttributeError raised from api_batch.execute() below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def sync_batches(self, stream_objects): refs = load_shared_schema_refs() schema = singer.resolve_schema_references(self.catalog_entry.schema.to_dict(), refs) @@ -477,12 +488,14 @@ def sync_batches(self, stream_objects): api_batch.execute() return str(pendulum.parse(latest_lead[self.replication_key])) - @retry_pattern(backoff.expo, FacebookRequestError, max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from account.get_ads() below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def get_ads(self): params = {'limit': RESULT_RETURN_LIMIT} yield from self.account.get_ads(params=params) - @retry_pattern(backoff.expo, FacebookRequestError, max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from ad.get_leads() below + @retry_pattern(backoff.expo, (FacebookRequestError, AttributeError), max_tries=5, factor=5) def get_leads(self, ads, start_time, previous_start_time): start_time = int(start_time.timestamp()) # Get unix timestamp params = {'limit': RESULT_RETURN_LIMIT, @@ -633,7 +646,8 @@ def __api_get_with_retry(job): job = job.api_get() return job - @retry_pattern(backoff.expo, (FacebookRequestError, InsightsJobTimeout, FacebookBadObjectError, TypeError), max_tries=5, factor=5) + # Added retry_pattern to handle AttributeError raised from requests call below + @retry_pattern(backoff.expo, (FacebookRequestError, InsightsJobTimeout, FacebookBadObjectError, TypeError, AttributeError), max_tries=5, factor=5) def run_job(self, params): LOGGER.info('Starting adsinsights job with params %s', params) job = self.account.get_insights( # pylint: disable=no-member diff --git a/tests/unittests/test_attribute_error_retry.py b/tests/unittests/test_attribute_error_retry.py new file mode 100644 index 00000000..63949a2b --- /dev/null +++ b/tests/unittests/test_attribute_error_retry.py @@ -0,0 +1,193 @@ +import unittest +from unittest.mock import Mock +from unittest import mock +from tap_facebook import AdCreative, Ads, AdSets, Campaigns, AdsInsights, Leads + +@mock.patch("time.sleep") +class TestAttributErrorBackoff(unittest.TestCase): + """A set of unit tests to ensure that requests are retrying properly for AttributeError Error""" + def test_get_adcreatives(self, mocked_sleep): + """ + AdCreative.get_adcreatives calls a `facebook_business` method,`get_ad_creatives()`, to get a batch of ad creatives. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + + # Mock get_ad_creatives function to throw AttributeError exception + mocked_account = Mock() + mocked_account.get_ad_creatives = Mock() + mocked_account.get_ad_creatives.side_effect = AttributeError + + # Call get_adcreatives() function of AdCreatives and verify AttributeError is raised + ad_creative_object = AdCreative('', mocked_account, '', '') + with self.assertRaises(AttributeError): + ad_creative_object.get_adcreatives() + + # verify get_ad_creatives() is called 5 times as max 5 reties provided for function + self.assertEquals(mocked_account.get_ad_creatives.call_count, 5) + + def test_call_get_ads(self, mocked_sleep): + """ + Ads._call_get_ads calls a `facebook_business` method,`get_ads()`, to get a batch of ads. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + + # Mock get_ads function to throw AttributeError exception + mocked_account = Mock() + mocked_account.get_ads = Mock() + mocked_account.get_ads.side_effect = AttributeError + + # Call _call_get_ads() function of Ads and verify AttributeError is raised + ad_object = Ads('', mocked_account, '', '', '') + with self.assertRaises(AttributeError): + ad_object._call_get_ads('test') + + # verify get_ads() is called 5 times as max 5 reties provided for function + self.assertEquals(mocked_account.get_ads.call_count, 5) + + @mock.patch("pendulum.parse") + def test_ad_prepare_record(self, mocked_parse, mocked_sleep): + """ + __iter__ of Ads calls a function _iterate which calls a nested prepare_record function. + Prepare_record calls a `facebook_business` method,`ad.api_get()`, to get a ad fields. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + # Mock ad object + mocked_ad = Mock() + mocked_ad.api_get = Mock() + mocked_ad.__getitem__ = Mock() + mocked_ad.api_get.side_effect = AttributeError + + # # Mock get_ads function return mocked ad object + mocked_account = Mock() + mocked_account.get_ads = Mock() + mocked_account.get_ads.side_effect = [[mocked_ad]] + + # Iterate ads object which calls prepare_record() inside and verify AttributeError is raised + ad_object = Ads('', mocked_account, '', '', '') + with self.assertRaises(AttributeError): + for message in ad_object: + pass + + # verify prepare_record() function by checking call count of mocked ad.api_get() + self.assertEquals(mocked_ad.api_get.call_count, 5) + + def test__call_get_ad_sets(self, mocked_sleep): + """ + AdSets._call_get_ad_sets calls a `facebook_business` method,`get_ad_sets()`, to get a batch of adsets. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + + # Mock get_ad_sets function to throw AttributeError exception + mocked_account = Mock() + mocked_account.get_ad_sets = Mock() + mocked_account.get_ad_sets.side_effect = AttributeError + + # Call _call_get_ad_sets() function of AdSets and verify AttributeError is raised + ad_set_object = AdSets('', mocked_account, '', '', '') + with self.assertRaises(AttributeError): + ad_set_object._call_get_ad_sets('test') + + # verify get_ad_sets() is called 5 times as max 5 reties provided for function + self.assertEquals(mocked_account.get_ad_sets.call_count, 5) + + @mock.patch("pendulum.parse") + def test_adset_prepare_record(self, mocked_parse, mocked_sleep): + """ + __iter__ of AdSets calls a function _iterate which calls a nested prepare_record function. + Prepare_record calls a `facebook_business` method,`ad.api_get()`, to get a ad fields. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + + # Mock adset object + mocked_adset = Mock() + mocked_adset.api_get = Mock() + mocked_adset.__getitem__ = Mock() + mocked_adset.api_get.side_effect = AttributeError + + # Mock get_ad_sets function return mocked ad object + mocked_account = Mock() + mocked_account.get_ad_sets = Mock() + mocked_account.get_ad_sets.side_effect = [[mocked_adset]] + + # Iterate adset object which calls prepare_record() inside and verify AttributeError is raised + ad_set_object = AdSets('', mocked_account, '', '', '') + with self.assertRaises(AttributeError): + for message in ad_set_object: + pass + + # verify prepare_record() function by checking call count of mocked ad.api_get() + self.assertEquals(mocked_adset.api_get.call_count, 5) + + def test__call_get_campaigns(self, mocked_sleep): + """ + Campaigns._call_get_campaigns calls a `facebook_business` method,`get_campaigns()`, to get a batch of campaigns. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + + # Mock get_campaigns function to throw AttributeError exception + mocked_account = Mock() + mocked_account.get_campaigns = Mock() + mocked_account.get_campaigns.side_effect = AttributeError + + # Call _call_get_campaigns() function of Campaigns and verify AttributeError is raised + campaigns_object = Campaigns('', mocked_account, '', '', '') + with self.assertRaises(AttributeError): + campaigns_object._call_get_campaigns('test') + + # verify get_campaigns() is called 5 times as max 5 reties provided for function + self.assertEquals(mocked_account.get_campaigns.call_count, 5) + + @mock.patch("pendulum.parse") + def test_campaign_prepare_record(self, mocked_parse, mocked_sleep): + """ + __iter__ of Campaigns calls a function _iterate which calls a nested prepare_record function. + Prepare_record calls a `facebook_business` method,`ad.api_get()`, to get a ad fields. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + + # # Mock campaign object + mocked_campaign = Mock() + mocked_campaign.api_get = Mock() + mocked_campaign.__getitem__ = Mock() + mocked_campaign.api_get.side_effect = AttributeError + + # # Mock get_campaigns function return mocked ad object + mocked_account = Mock() + mocked_account.get_campaigns = Mock() + mocked_account.get_campaigns.side_effect = [[mocked_campaign]] + + # Iterate campaigns object which calls prepare_record() inside and verify AttributeError is raised + campaign_object = Campaigns('', mocked_account, '', '', '') + with self.assertRaises(AttributeError): + for message in campaign_object: + pass + + # verify prepare_record() function by checking call count of mocked ad.api_get() + self.assertEquals(mocked_campaign.api_get.call_count, 5) + + def test_run_job(self, mocked_sleep): + """ + AdsInsights.run_job calls a `facebook_business` method,`get_insights()`, to get a batch of insights. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + + # Mock get_insights function to throw AttributeError exception + mocked_account = Mock() + mocked_account.get_insights = Mock() + mocked_account.get_insights.side_effect = AttributeError + + # Call run_job() function of Campaigns and verify AttributeError is raised + ads_insights_object = AdsInsights('', mocked_account, '', '', '', {}) + with self.assertRaises(AttributeError): + ads_insights_object.run_job('test') + + # verify get_insights() is called 5 times as max 5 reties provided for function + self.assertEquals(mocked_account.get_insights.call_count, 5) diff --git a/tests/unittests/test_sync_batches_retry.py b/tests/unittests/test_sync_batches_retry.py new file mode 100644 index 00000000..53cccbfa --- /dev/null +++ b/tests/unittests/test_sync_batches_retry.py @@ -0,0 +1,154 @@ +import unittest +from unittest import mock +from unittest.mock import Mock +from tap_facebook import FacebookRequestError +from tap_facebook import AdCreative, Leads +from singer import resolve_schema_references +from singer.schema import Schema +from singer.catalog import CatalogEntry + +# Mock object for the batch object to raise exception +class MockBatch: + + def __init__(self, exception="NoException"): + self.exception = exception + + def execute(self): + if self.exception == "AttributeError": + raise AttributeError("'str' object has no attribute 'get'") + elif self.exception == "FacebookRequestError": + raise FacebookRequestError( + message='', + request_context={"":Mock()}, + http_status=500, + http_headers=Mock(), + body={} + ) + +class TestAdCreativeSyncBbatches(unittest.TestCase): + + @mock.patch("tap_facebook.API") + @mock.patch("singer.resolve_schema_references") + def test_retries_on_attribute_error_sync_batches(self, mocked_schema, mocked_api): + """ + AdCreative.sync_batches calls a `facebook_business` method,`api_batch.execute()`, to get a batch of ad creatives. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + # Mock new_batch() function of API + mocked_api.new_batch = Mock() + mocked_api.new_batch.return_value = MockBatch(exception="AttributeError") # Raise AttributeError exception + + # Initialize AdCreative and mock catalog_entry + mock_catalog_entry = CatalogEntry(schema=Schema()) + ad_creative_object = AdCreative('', '', '', '') + ad_creative_object.catalog_entry = mock_catalog_entry + + # Call sync_batches() function of AdCreatives and verify AttributeError is raised + with self.assertRaises(AttributeError): + ad_creative_object.sync_batches([]) + + # verify calls inside sync_batches are called 5 times as max 5 retries provided for function + self.assertEquals(5, mocked_api.new_batch.call_count) + self.assertEquals(5, mocked_schema.call_count) + + @mock.patch("tap_facebook.API") + @mock.patch("singer.resolve_schema_references") + def test_retries_on_facebook_request_error_sync_batches(self, mocked_schema, mocked_api): + """ + AdCreative.sync_batches calls a `facebook_business` method,`api_batch.execute()`, to get a batch of ad creatives. + We mock this method to raise a `FacebookRequestError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + # Mock new_batch() function of API + mocked_api.new_batch = Mock() + mocked_api.new_batch.return_value = MockBatch(exception="FacebookRequestError") # Raise FacebookRequestError exception + + # Initialize AdCreative and mock catalog_entry + mock_catalog_entry = CatalogEntry(schema=Schema()) + ad_creative_object = AdCreative('', '', '', '') + ad_creative_object.catalog_entry = mock_catalog_entry + + # Call sync_batches() function of AdCreatives and verify FacebookRequestError is raised + with self.assertRaises(FacebookRequestError): + ad_creative_object.sync_batches([]) + + # verify calls inside sync_batches are called 5 times as max 5 reties provided for function + self.assertEquals(5, mocked_api.new_batch.call_count) + self.assertEquals(5, mocked_schema.call_count) + + @mock.patch("tap_facebook.API") + @mock.patch("singer.resolve_schema_references") + def test_no_error_on_sync_batches(self, mocked_schema, mocked_api): + """ + AdCreative.sync_batches calls a `facebook_business` method,`api_batch.execute()`, to get a batch of ad creatives. + We mock this method to simply pass the things and expect the tap to run without exception + """ + # Mock new_batch() function of API + mocked_api.new_batch = Mock() + mocked_api.new_batch.return_value = MockBatch() # No exception + + # Initialize AdCreative and mock catalog_entry + mock_catalog_entry = CatalogEntry(schema=Schema()) + ad_creative_object = AdCreative('', '', '', '') + ad_creative_object.catalog_entry = mock_catalog_entry + + # Call sync_batches() function of AdCreatives + ad_creative_object.sync_batches([]) + + # verify calls inside sync_batches are called once as no exception is thrown + self.assertEquals(1, mocked_api.new_batch.call_count) + self.assertEquals(1, mocked_schema.call_count) + + +class TestLeadsSyncBatches(unittest.TestCase): + + @mock.patch("tap_facebook.API") + @mock.patch("singer.resolve_schema_references") + def test_retries_on_attribute_error_sync_batches(self, mocked_schema, mocked_api): + """ + Leads.sync_batches calls a `facebook_business` method,`api_batch.execute()`, to get a batch of Leads. + We mock this method to raise a `AttributeError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + # Mock new_batch() function of API + mocked_api.new_batch = Mock() + mocked_api.new_batch.return_value = MockBatch(exception="AttributeError") # Raise AttributeError exception + + # Initialize Leads and mock catalog_entry + mock_catalog_entry = CatalogEntry(schema=Schema()) + leads_object = Leads('', '', '', '', '') + leads_object.catalog_entry = mock_catalog_entry + + # Call sync_batches() function of Leads and verify AttributeError is raised + with self.assertRaises(AttributeError): + leads_object.sync_batches([]) + + # verify calls inside sync_batches are called 5 times as max 5 reties provided for function + self.assertEquals(5, mocked_api.new_batch.call_count) + self.assertEquals(5, mocked_schema.call_count) + + @mock.patch("tap_facebook.API") + @mock.patch("singer.resolve_schema_references") + def test_retries_on_facebook_request_error_sync_batches(self, mocked_schema, mocked_api): + """ + Leads.sync_batches calls a `facebook_business` method,`api_batch.execute()`, to get a batch of Leads. + We mock this method to raise a `FacebookRequestError` and expect the tap to retry this that function up to 5 times, + which is the current hard coded `max_tries` value. + """ + # Mock new_batch() function of API + mocked_api.new_batch = Mock() + mocked_api.new_batch.return_value = MockBatch(exception="FacebookRequestError") # Raise FacebookRequestError exception + + # Initialize Leads and mock catalog_entry + mock_catalog_entry = CatalogEntry(schema=Schema()) + leads_object = Leads('', '', '', '', '') + leads_object.catalog_entry = mock_catalog_entry + + # Call sync_batches() function of Leads and verify FacebookRequestError is raised + with self.assertRaises(FacebookRequestError): + leads_object.sync_batches([]) + + # verify calls inside sync_batches are called 5 times as max 5 reties provided for function + self.assertEquals(5, mocked_api.new_batch.call_count) + self.assertEquals(5, mocked_schema.call_count)