Skip to content

Commit 9707b2a

Browse files
committed
feat: add more semantics to API exceptions related to subsidy fulfillment.
ENT-7271
1 parent dda40f5 commit 9707b2a

File tree

4 files changed

+279
-31
lines changed

4 files changed

+279
-31
lines changed
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""
2+
Version 1 API Exceptions.
3+
"""
4+
from rest_framework import status
5+
from rest_framework.exceptions import APIException
6+
7+
from enterprise_access.apps.api_client.lms_client import LmsApiClient
8+
from enterprise_access.apps.subsidy_access_policy import constants
9+
10+
11+
class RedemptionRequestException(APIException):
12+
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
13+
default_detail = 'Could not redeem'
14+
15+
16+
class SubsidyAPIRedemptionRequestException(RedemptionRequestException):
17+
"""
18+
An API exception that has a response payload structured like
19+
{
20+
'code': 'some_error_code',
21+
'detail': {
22+
'reason': 'reason_for_error',
23+
'user_message': 'User friendly string describing the error.',
24+
# additional metadata describing the error, possibly including admin emails.
25+
'metadata': {
26+
'key': 'value',
27+
}
28+
}
29+
}
30+
31+
There are some sane defaults set at initialization for the reason, code, and user_message
32+
values.
33+
"""
34+
default_detail = 'Error redeeming through Subsidy API'
35+
default_code = constants.SubsidyRedemptionErrorCodes.DEFAULT_ERROR
36+
37+
# Custom keys of the `detail` field returned in the response payload.
38+
default_reason = constants.SubsidyRedemptionErrorReasons.DEFAULT_REASON
39+
default_user_message = constants.SubsidyRedemptionErrorReasons.USER_MESSAGES_BY_REASON[default_reason]
40+
41+
def __init__(self, code=None, detail=None, policy=None, subsidy_api_error=None):
42+
"""
43+
Initializes all of the attributes of this exception instance.
44+
45+
args:
46+
code (str): A reusable error code constant.
47+
detail ([list,str,dict]): Details about the exception we're raising.
48+
policy (SubsidyAccessPolicy): A policy object, from which we can fetch admin email addresses.
49+
subsidy_api_error (SubsidyAPIHTTPError): The exception object that was caught, from which
50+
we can infer more specific causes about the redemption error this exception encapsulates.
51+
"""
52+
super().__init__(code=code, detail=detail)
53+
54+
self.reason = self.default_reason
55+
self.user_message = self.default_user_message
56+
self.metadata = {}
57+
58+
if policy and subsidy_api_error:
59+
try:
60+
self._build_subsidy_api_error_payload(policy, subsidy_api_error)
61+
except Exception: # pylint: disable=broad-except
62+
self.metadata = {
63+
'subsidy_error_detail_raw': str(subsidy_api_error.error_response.content),
64+
}
65+
66+
self.detail = {
67+
'code': self.code,
68+
'detail': {
69+
'reason': self.reason,
70+
'user_message': self.user_message,
71+
'metadata': self.metadata,
72+
}
73+
}
74+
75+
def _build_subsidy_api_error_payload(self, policy, subsidy_api_error):
76+
"""
77+
Helper to build error response payload on Subsidy API errors.
78+
"""
79+
subsidy_error_detail = subsidy_api_error.error_payload().get('detail')
80+
subsidy_error_code = subsidy_api_error.error_payload().get('code')
81+
82+
self.metadata = {
83+
'enterprise_administrators': LmsApiClient().get_enterprise_customer_data(
84+
policy.enterprise_customer_uuid
85+
).get('admin_users')
86+
}
87+
88+
# We currently only have enough data to say more specific things
89+
# about fulfillment errors during subsidy API redemption.
90+
if subsidy_error_code == constants.SubsidyRedemptionErrorCodes.FULFILLMENT_ERROR:
91+
self._set_subsidy_fulfillment_error_reason(subsidy_error_detail)
92+
93+
def _set_subsidy_fulfillment_error_reason(self, subsidy_error_detail):
94+
"""
95+
Helper to set the reason, user_message, and metadata
96+
for the given subsidy_error_detail.
97+
"""
98+
self.metadata['subsidy_error_detail'] = subsidy_error_detail
99+
self.reason = constants.SubsidyFulfillmentErrorReasons.DEFAULT_REASON
100+
101+
if subsidy_error_detail:
102+
message_string = self._get_subsidy_fulfillment_error_message(subsidy_error_detail)
103+
if cause_of_message := constants.SubsidyFulfillmentErrorReasons.get_cause_from_error_message(
104+
message_string
105+
):
106+
self.reason = cause_of_message
107+
# pylint: disable=attribute-defined-outside-init
108+
self.code = constants.SubsidyRedemptionErrorCodes.FULFILLMENT_ERROR
109+
110+
self.user_message = constants.SubsidyFulfillmentErrorReasons.USER_MESSAGES_BY_REASON.get(self.reason)
111+
112+
def _get_subsidy_fulfillment_error_message(self, subsidy_error_detail):
113+
"""
114+
``subsidy_error_detail`` is either a string describing an error message,
115+
a dict with a "message" key describing an error message, or a list of such
116+
dicts. This helper method widdles any of those things down into a single
117+
error message string.
118+
"""
119+
if isinstance(subsidy_error_detail, str):
120+
return subsidy_error_detail
121+
122+
subsidy_message_dict = subsidy_error_detail
123+
if isinstance(subsidy_error_detail, list):
124+
subsidy_message_dict = subsidy_error_detail[0]
125+
126+
return subsidy_message_dict.get('message')
127+
128+
129+
class SubsidyAccessPolicyLockedException(APIException):
130+
"""
131+
Throw this exception when an attempt to acquire a policy lock failed because it was already locked by another agent.
132+
133+
Note: status.HTTP_423_LOCKED is NOT acceptable as a status code for delivery to web browsers. According to Mozilla:
134+
135+
> The ability to lock a resource is specific to some WebDAV servers. Browsers accessing web pages will never
136+
> encounter this status code; in the erroneous cases it happens, they will handle it as a generic 400 status code.
137+
138+
See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/423
139+
140+
HTTP 429 Too Many Requests is the next best thing, and implies retryability.
141+
"""
142+
status_code = status.HTTP_429_TOO_MANY_REQUESTS
143+
default_detail = 'Enrollment currently locked for this subsidy access policy.'

enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from uuid import UUID, uuid4
88

99
import ddt
10+
import requests
1011
from django.conf import settings
1112
from rest_framework import status
1213
from rest_framework.reverse import reverse
@@ -22,6 +23,8 @@
2223
AccessMethods,
2324
MissingSubsidyAccessReasonUserMessages,
2425
PolicyTypes,
26+
SubsidyFulfillmentErrorReasons,
27+
SubsidyRedemptionErrorCodes,
2528
TransactionStateChoices
2629
)
2730
from enterprise_access.apps.subsidy_access_policy.tests.factories import (
@@ -682,6 +685,65 @@ def test_redeem_policy(self, mock_transactions_cache_for_learner): # pylint: di
682685
),
683686
)
684687

688+
@mock.patch('enterprise_access.apps.subsidy_access_policy.models.get_and_cache_transactions_for_learner')
689+
@mock.patch('enterprise_access.apps.api.v1.exceptions.LmsApiClient')
690+
@ddt.data(
691+
{
692+
'subsidy_error_code': 'fulfillment_error',
693+
'subsidy_error_detail': [
694+
{'message': 'woozit duplicate order woohoo!'},
695+
],
696+
'expected_redeem_error_detail': {
697+
'reason': SubsidyFulfillmentErrorReasons.DUPLICATE_FULFILLMENT,
698+
'user_message': SubsidyFulfillmentErrorReasons.USER_MESSAGES_BY_REASON[
699+
SubsidyFulfillmentErrorReasons.DUPLICATE_FULFILLMENT
700+
],
701+
'metadata': {
702+
'enterprise_administrators': [{'email': 'edx@example.com'}],
703+
'subsidy_error_detail': [
704+
{'message': 'woozit duplicate order woohoo!'}
705+
],
706+
},
707+
},
708+
'expected_redeem_error_code': SubsidyRedemptionErrorCodes.FULFILLMENT_ERROR,
709+
},
710+
)
711+
@ddt.unpack
712+
def test_redeem_policy_subsidy_api_error(
713+
self, mock_lms_api_client, mock_transactions_cache_for_learner, # pylint: disable=unused-argument
714+
subsidy_error_code, subsidy_error_detail,
715+
expected_redeem_error_detail, expected_redeem_error_code
716+
):
717+
"""
718+
Verify that SubsidyAccessPolicyRedeemViewset redeem endpoint returns a well-structured
719+
error response payload when the subsidy API call to redeem/fulfill responds with an error.
720+
"""
721+
mock_lms_api_client().get_enterprise_customer_data.return_value = {
722+
'slug': 'the-slug',
723+
'admin_users': [{'email': 'edx@example.com'}],
724+
}
725+
self.mock_get_content_metadata.return_value = {'content_price': 123}
726+
mock_response = mock.MagicMock()
727+
mock_response.json.return_value = {
728+
'code': subsidy_error_code,
729+
'detail': subsidy_error_detail,
730+
}
731+
self.redeemable_policy.subsidy_client.create_subsidy_transaction.side_effect = requests.exceptions.HTTPError(
732+
response=mock_response
733+
)
734+
735+
payload = {
736+
'lms_user_id': 1234,
737+
'content_key': 'course-v1:edX+edXPrivacy101+3T2020',
738+
}
739+
740+
response = self.client.post(self.subsidy_access_policy_redeem_endpoint, payload)
741+
742+
response_json = self.load_json(response.content)
743+
self.maxDiff = None
744+
self.assertEqual(response_json['detail'], expected_redeem_error_detail)
745+
self.assertEqual(response_json['code'], expected_redeem_error_code)
746+
685747
@mock.patch('enterprise_access.apps.subsidy_access_policy.models.get_and_cache_transactions_for_learner')
686748
def test_redeem_policy_with_metadata(self, mock_transactions_cache_for_learner): # pylint: disable=unused-argument
687749
"""

enterprise_access/apps/api/v1/views/subsidy_access_policy.py

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from rest_framework import serializers as rest_serializers
2222
from rest_framework import status, viewsets
2323
from rest_framework.decorators import action
24-
from rest_framework.exceptions import APIException, NotFound
24+
from rest_framework.exceptions import NotFound
2525
from rest_framework.generics import get_object_or_404
2626
from rest_framework.response import Response
2727

@@ -57,6 +57,11 @@
5757
)
5858
from enterprise_access.apps.subsidy_access_policy.subsidy_api import get_redemptions_by_content_and_policy_for_learner
5959

60+
from ..exceptions import (
61+
RedemptionRequestException,
62+
SubsidyAccessPolicyLockedException,
63+
SubsidyAPIRedemptionRequestException
64+
)
6065
from .utils import PaginationWithPageCount
6166

6267
logger = logging.getLogger(__name__)
@@ -244,28 +249,6 @@ def get_queryset(self):
244249
return queryset.filter(enterprise_customer_uuid=enterprise_customer_uuid)
245250

246251

247-
class RedemptionRequestException(APIException):
248-
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
249-
default_detail = 'Could not redeem'
250-
251-
252-
class SubsidyAccessPolicyLockedException(APIException):
253-
"""
254-
Throw this exception when an attempt to acquire a policy lock failed because it was already locked by another agent.
255-
256-
Note: status.HTTP_423_LOCKED is NOT acceptable as a status code for delivery to web browsers. According to Mozilla:
257-
258-
> The ability to lock a resource is specific to some WebDAV servers. Browsers accessing web pages will never
259-
> encounter this status code; in the erroneous cases it happens, they will handle it as a generic 400 status code.
260-
261-
See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/423
262-
263-
HTTP 429 Too Many Requests is the next best thing, and implies retryability.
264-
"""
265-
status_code = status.HTTP_429_TOO_MANY_REQUESTS
266-
default_detail = 'Enrollment currently locked for this subsidy access policy.'
267-
268-
269252
class SubsidyAccessPolicyRedeemViewset(UserDetailsFromJwtMixin, PermissionRequiredMixin, viewsets.GenericViewSet):
270253
"""
271254
Viewset for Subsidy Access Policy APIs.
@@ -484,11 +467,10 @@ def redeem(self, request, *args, **kwargs):
484467
logger.exception(exc)
485468
raise SubsidyAccessPolicyLockedException() from exc
486469
except SubsidyAPIHTTPError as exc:
487-
logger.exception(f'{exc} when creating transaction in subsidy API')
488-
error_payload = exc.error_payload()
489-
error_payload['detail'] = f"Subsidy Transaction API error: {error_payload['detail']}"
490-
raise RedemptionRequestException(
491-
detail=error_payload,
470+
logger.exception(f'{exc} when creating transaction in subsidy API with payload {exc.error_payload()}')
471+
raise SubsidyAPIRedemptionRequestException(
472+
policy=policy,
473+
subsidy_api_error=exc,
492474
) from exc
493475

494476
def get_existing_redemptions(self, policies, lms_user_id):
@@ -673,9 +655,7 @@ def _get_reasons_for_no_redeemable_policies(self, enterprise_customer_uuid, non_
673655
for each non-redeemable policy.
674656
"""
675657
reasons = []
676-
lms_client = LmsApiClient()
677-
enterprise_customer_data = lms_client.get_enterprise_customer_data(enterprise_customer_uuid)
678-
enterprise_admin_users = enterprise_customer_data.get('admin_users')
658+
enterprise_admin_users = self._get_enterprise_admin_users(enterprise_customer_uuid)
679659

680660
for reason, policies in non_redeemable_policies.items():
681661
reasons.append({
@@ -689,6 +669,14 @@ def _get_reasons_for_no_redeemable_policies(self, enterprise_customer_uuid, non_
689669

690670
return reasons
691671

672+
def _get_enterprise_admin_users(self, enterprise_customer_uuid):
673+
"""
674+
Helper to fetch admin users for the given customer uuid.
675+
"""
676+
lms_client = LmsApiClient()
677+
enterprise_customer_data = lms_client.get_enterprise_customer_data(enterprise_customer_uuid)
678+
return enterprise_customer_data.get('admin_users')
679+
692680
def _get_list_price(self, enterprise_customer_uuid, content_key):
693681
"""
694682
Determine the price for content for display purposes only.

enterprise_access/apps/subsidy_access_policy/constants.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
""" Constants for the subsidy_access_policy app. """
2+
import re
23

34

45
class AccessMethods:
@@ -99,3 +100,57 @@ class MissingSubsidyAccessReasonUserMessages:
99100
REASON_LEARNER_MAX_SPEND_REACHED = "learner_max_spend_reached"
100101
REASON_POLICY_SPEND_LIMIT_REACHED = "policy_spend_limit_reached"
101102
REASON_LEARNER_MAX_ENROLLMENTS_REACHED = "learner_max_enrollments_reached"
103+
104+
105+
class SubsidyRedemptionErrorCodes:
106+
"""
107+
Collection of error ``code`` values that the subsidy API's
108+
redeem endpoint might return in an error response payload.
109+
"""
110+
DEFAULT_ERROR = 'subsidy_redemption_error'
111+
FULFILLMENT_ERROR = 'fulfillment_error'
112+
113+
114+
class SubsidyRedemptionErrorReasons:
115+
"""
116+
Somewhat more generic collection of reasons that redemption may have
117+
failed in ways that are *not* related to fulfillment.
118+
"""
119+
DEFAULT_REASON = 'default_subsidy_redemption_error'
120+
121+
USER_MESSAGES_BY_REASON = {
122+
DEFAULT_REASON: "Something went wrong during subsidy redemption",
123+
}
124+
125+
126+
class SubsidyFulfillmentErrorReasons:
127+
"""
128+
Codifies standard reasons that fulfillment may have failed,
129+
along with a mapping of those reasons to user-friendly display messages.
130+
"""
131+
DEFAULT_REASON = 'default_fulfillment_error'
132+
DUPLICATE_FULFILLMENT = 'duplicate_fulfillment'
133+
134+
USER_MESSAGES_BY_REASON = {
135+
DEFAULT_REASON: "Something went wrong during fulfillment",
136+
DUPLICATE_FULFILLMENT: "A legacy fulfillment already exists for this content.",
137+
}
138+
139+
CAUSES_REGEXP_BY_REASON = {
140+
DUPLICATE_FULFILLMENT: re.compile(".*duplicate order.*"),
141+
}
142+
143+
@classmethod
144+
def get_cause_from_error_message(cls, message_string):
145+
"""
146+
Helper to find the cause of a given error message string
147+
by matching against the regexs mapped above.
148+
"""
149+
if not message_string:
150+
return None
151+
152+
for cause_of_message, regex in cls.CAUSES_REGEXP_BY_REASON.items():
153+
if regex.match(message_string):
154+
return cause_of_message
155+
156+
return None

0 commit comments

Comments
 (0)