diff --git a/openedx/core/djangoapps/programs/tasks/v1/tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tasks.py index b01ce61dba..6f54b32ea0 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tasks.py @@ -174,11 +174,32 @@ def award_program_certificates(self, username): try: award_program_certificate(credentials_client, username, program_uuid) LOGGER.info('Awarded certificate for program %s to user %s', program_uuid, username) - except exceptions.HttpClientError: + except exceptions.HttpNotFoundError: LOGGER.exception( - 'Certificate for program %s not configured, unable to award certificate to %s', - program_uuid, username + """Certificate for program {uuid} could not be found. Unable to award certificate to user + {username}. The program might not be configured.""".format(uuid=program_uuid, username=username) ) + except exceptions.HttpClientError as exc: + # Grab the status code from the client error, because our API + # client handles all 4XX errors the same way. In the future, + # we may want to fork slumber, add 429 handling, and use that + # in edx_rest_api_client. + # A status code looks like: + # "Client Error 429: http://example-endpoint/" + status_code = int(str(exc).split(':')[0][-3:]) + if status_code == 429: + rate_limit_countdown = 60 + LOGGER.info( + """Rate limited. Retrying task to award certificates to user {username} in {countdown} + seconds""".format(username=username, countdown=rate_limit_countdown) + ) + # Retry after 60 seconds, when we should be in a new throttling window + raise self.retry(exc=exc, countdown=rate_limit_countdown, max_retries=MAX_RETRIES) + else: + LOGGER.exception( + """Unable to award certificate to user {username} for program {uuid}. The program might not be + configured.""".format(username=username, uuid=program_uuid) + ) except Exception: # pylint: disable=broad-except # keep trying to award other certs, but retry the whole task to fix any missing entries LOGGER.warning('Failed to award certificate for program {uuid} to user {username}.'.format( diff --git a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py index 44075b86fc..5b595374ca 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py @@ -292,6 +292,42 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo self.assertEqual(mock_get_certified_programs.call_count, 2) self.assertEqual(mock_award_program_certificate.call_count, 1) + def test_retry_on_credentials_api_429_error( + self, + mock_get_completed_programs, + mock_get_certified_programs, # pylint: disable=unused-argument + mock_award_program_certificate, + ): + """ + Verify that a 429 error causes the task to fail and then retry. + """ + mock_get_completed_programs.return_value = [1, 2] + mock_award_program_certificate.side_effect = self._make_side_effect( + [exceptions.HttpClientError('Client Error 429: http://example-endpoint/'), None] + ) + + tasks.award_program_certificates.delay(self.student.username).get() + + self.assertEqual(mock_award_program_certificate.call_count, 3) + + def test_no_retry_oncredentials_api_404_error( + self, + mock_get_completed_programs, + mock_get_certified_programs, # pylint: disable=unused-argument + mock_award_program_certificate, + ): + """ + Verify that a 404 error causes the task to fail but there is no retry. + """ + mock_get_completed_programs.return_value = [1, 2] + mock_award_program_certificate.side_effect = self._make_side_effect( + [exceptions.HttpNotFoundError(), None] + ) + + tasks.award_program_certificates.delay(self.student.username).get() + + self.assertEqual(mock_award_program_certificate.call_count, 2) + def test_no_retry_on_credentials_api_not_found_errors( self, mock_get_completed_programs, @@ -301,7 +337,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo mock_get_completed_programs.return_value = [1, 2] mock_get_certified_programs.side_effect = [[], [2]] mock_award_program_certificate.side_effect = self._make_side_effect( - [exceptions.HttpClientError(), None] + [exceptions.HttpClientError('Client Error 418: http://example-endpoint/'), None] ) tasks.award_program_certificates.delay(self.student.username).get()