Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion optimizely/helpers/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class Errors:
'This version of the Python SDK does not support the given datafile version: "{}".')
INVALID_SEGMENT_IDENTIFIER = 'Audience segments fetch failed (invalid identifier).'
FETCH_SEGMENTS_FAILED = 'Audience segments fetch failed ({}).'
ODP_EVENT_FAILED = 'ODP event send failed (invalid url).'
ODP_EVENT_FAILED = 'ODP event send failed ({}).'
ODP_NOT_ENABLED = 'ODP is not enabled. '


Expand Down
27 changes: 27 additions & 0 deletions optimizely/odp/odp_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copyright 2022, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from typing import Any, Dict


class OdpEvent:
""" Representation of an odp event which can be sent to the Optimizely odp platform. """

def __init__(self, type: str, action: str,
identifiers: Dict[str, str], data: Dict[str, Any]) -> None:
self.type = type,
self.action = action,
self.identifiers = identifiers,
self.data = data
94 changes: 94 additions & 0 deletions optimizely/odp/zaius_rest_api_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright 2022, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import json
from typing import Optional, List

import requests
from requests.exceptions import RequestException, ConnectionError, Timeout

from optimizely import logger as optimizely_logger
from optimizely.helpers.enums import Errors, OdpRestApiConfig
from optimizely.odp.odp_event import OdpEvent

"""
ODP REST Events API
- https://api.zaius.com/v3/events
- test ODP public API key = "W4WzcEs-ABgXorzY7h1LCQ"

[Event Request]
curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d
'{"type":"fullstack","action":"identified","identifiers":{"vuid": "123","fs_user_id": "abc"},
"data":{"idempotence_id":"xyz","source":"swift-sdk"}}' https://api.zaius.com/v3/events
[Event Response]
{"title":"Accepted","status":202,"timestamp":"2022-06-30T20:59:52.046Z"}
"""


class ZaiusRestApiManager:
"""Provides an internal service for ODP event REST api access."""

def __init__(self, logger: Optional[optimizely_logger.Logger] = None):
self.logger = logger or optimizely_logger.NoOpLogger()

def send_odp_events(self, api_key: str, api_host: str, events: List[OdpEvent]) -> bool:
"""
Dispatch the event being represented by the OdpEvent object.

Args:
api_key: public api key
api_host: domain url of the host
events: list of odp events to be sent to optimizely's odp platform.

Returns:
retry is True - if network or server error (5xx), otherwise False
"""
should_retry: bool = False
url = f'{api_host}/v3/events'
request_headers = {'content-type': 'application/json', 'x-api-key': api_key}

try:
payload_dict = json.dumps(events)
except TypeError as err:
self.logger.error(Errors.ODP_EVENT_FAILED.format(err))
return should_retry

try:
response = requests.post(url=url,
headers=request_headers,
data=payload_dict,
timeout=OdpRestApiConfig.REQUEST_TIMEOUT)

response.raise_for_status()

except (ConnectionError, Timeout):
self.logger.error(Errors.ODP_EVENT_FAILED.format('network error'))
# retry on network errors
should_retry = True
except RequestException as err:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so, but can you confirm that this catches all other exceptions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if err.response is not None:
if 400 <= err.response.status_code < 500:
# log 4xx
self.logger.error(Errors.ODP_EVENT_FAILED.format(err.response.text))
else:
# log 5xx
self.logger.error(Errors.ODP_EVENT_FAILED.format(err))
# retry on 500 exceptions
should_retry = True
else:
# log exceptions without response body (i.e. invalid url)
self.logger.error(Errors.ODP_EVENT_FAILED.format(err))

return should_retry
17 changes: 17 additions & 0 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@

import json
import unittest
from typing import Optional

from requests import Response

from optimizely import optimizely

Expand All @@ -28,6 +31,20 @@ def assertStrictTrue(self, to_assert):
def assertStrictFalse(self, to_assert):
self.assertIs(to_assert, False)

def fake_server_response(self, status_code: int = None, content: Optional[str] = None,
url: str = None) -> Optional[Response]:
"""Mock the server response."""
response = Response()

if status_code:
response.status_code = status_code
if content:
response._content = content.encode('utf-8')
if url:
response.url = url

return response

def setUp(self, config_dict='config_dict'):
self.config_dict = {
'revision': '42',
Expand Down
21 changes: 6 additions & 15 deletions tests/test_odp_zaius_graphql_api_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@
import json
from unittest import mock

from requests import Response
from requests import exceptions as request_exception
from optimizely.helpers.enums import OdpGraphQLApiConfig

from optimizely.helpers.enums import OdpGraphQLApiConfig
from optimizely.odp.zaius_graphql_api_manager import ZaiusGraphQLApiManager
from . import base

Expand Down Expand Up @@ -176,7 +175,8 @@ def test_fetch_qualified_segments__name_invalid(self):
def test_fetch_qualified_segments__invalid_key(self):
with mock.patch('requests.post') as mock_request_post, \
mock.patch('optimizely.logger') as mock_logger:
mock_request_post.return_value.json.return_value = json.loads(self.invalid_edges_key_response_data)
mock_request_post.return_value = self.fake_server_response(status_code=200,
content=self.invalid_edges_key_response_data)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand All @@ -191,7 +191,8 @@ def test_fetch_qualified_segments__invalid_key(self):
def test_fetch_qualified_segments__invalid_key_in_error_body(self):
with mock.patch('requests.post') as mock_request_post, \
mock.patch('optimizely.logger') as mock_logger:
mock_request_post.return_value.json.return_value = json.loads(self.invalid_key_for_error_response_data)
mock_request_post.return_value = self.fake_server_response(status_code=200,
content=self.invalid_key_for_error_response_data)

api = ZaiusGraphQLApiManager(logger=mock_logger)
api.fetch_segments(api_key=self.api_key,
Expand Down Expand Up @@ -265,17 +266,7 @@ def test_make_subset_filter(self):
self.assertEqual("(subset:[\"a\", \"b\", \"c\"])", api.make_subset_filter(["a", "b", "c"]))
self.assertEqual("(subset:[\"a\", \"b\", \"don't\"])", api.make_subset_filter(["a", "b", "don't"]))

# fake server response function and test json responses

@staticmethod
def fake_server_response(status_code=None, content=None, url=None):
"""Mock the server response."""
response = Response()
response.status_code = status_code
if content:
response._content = content.encode('utf-8')
response.url = url
return response
# test json responses

good_response_data = """
{
Expand Down
139 changes: 139 additions & 0 deletions tests/test_odp_zaius_rest_api_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Copyright 2022, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
from unittest import mock

from requests import exceptions as request_exception

from optimizely.helpers.enums import OdpRestApiConfig
from optimizely.odp.zaius_rest_api_manager import ZaiusRestApiManager
from . import base


class ZaiusRestApiManagerTest(base.BaseTest):
user_key = "vuid"
user_value = "test-user-value"
api_key = "test-api-key"
api_host = "test-host"

events = [
{"type": "t1", "action": "a1", "identifiers": {"id-key-1": "id-value-1"}, "data": {"key-1": "value1"}},
{"type": "t2", "action": "a2", "identifiers": {"id-key-2": "id-value-2"}, "data": {"key-2": "value2"}},
]

def test_send_odp_events__valid_request(self):
with mock.patch('requests.post') as mock_request_post:
api = ZaiusRestApiManager()
api.send_odp_events(api_key=self.api_key,
api_host=self.api_host,
events=self.events)

request_headers = {'content-type': 'application/json', 'x-api-key': self.api_key}
mock_request_post.assert_called_once_with(url=self.api_host + "/v3/events",
headers=request_headers,
data=json.dumps(self.events),
timeout=OdpRestApiConfig.REQUEST_TIMEOUT)

def test_send_odp_ovents_success(self):
with mock.patch('requests.post') as mock_request_post:
# no need to mock url and content because we're not returning the response
mock_request_post.return_value = self.fake_server_response(status_code=200)

api = ZaiusRestApiManager()
should_retry = api.send_odp_events(api_key=self.api_key,
api_host=self.api_host,
events=self.events) # content of events doesn't matter for the test

self.assertFalse(should_retry)

def test_send_odp_events_invalid_json_no_retry(self):
events = {1, 2, 3} # using a set to trigger JSON-not-serializable error

with mock.patch('requests.post') as mock_request_post, \
mock.patch('optimizely.logger') as mock_logger:
api = ZaiusRestApiManager(logger=mock_logger)
should_retry = api.send_odp_events(api_key=self.api_key,
api_host=self.api_host,
events=events)

self.assertFalse(should_retry)
mock_request_post.assert_not_called()
mock_logger.error.assert_called_once_with(
'ODP event send failed (Object of type set is not JSON serializable).')

def test_send_odp_events_invalid_url_no_retry(self):
invalid_url = 'https://*api.zaius.com'

with mock.patch('requests.post',
side_effect=request_exception.InvalidURL('Invalid URL')) as mock_request_post, \
mock.patch('optimizely.logger') as mock_logger:
api = ZaiusRestApiManager(logger=mock_logger)
should_retry = api.send_odp_events(api_key=self.api_key,
api_host=invalid_url,
events=self.events)

self.assertFalse(should_retry)
mock_request_post.assert_called_once()
mock_logger.error.assert_called_once_with('ODP event send failed (Invalid URL).')

def test_send_odp_events_network_error_retry(self):
with mock.patch('requests.post',
side_effect=request_exception.ConnectionError('Connection error')) as mock_request_post, \
mock.patch('optimizely.logger') as mock_logger:
api = ZaiusRestApiManager(logger=mock_logger)
should_retry = api.send_odp_events(api_key=self.api_key,
api_host=self.api_host,
events=self.events)

self.assertTrue(should_retry)
mock_request_post.assert_called_once()
mock_logger.error.assert_called_once_with('ODP event send failed (network error).')

def test_send_odp_events_400_no_retry(self):
with mock.patch('requests.post') as mock_request_post, \
mock.patch('optimizely.logger') as mock_logger:
mock_request_post.return_value = self.fake_server_response(status_code=400,
url=self.api_host,
content=self.failure_response_data)

api = ZaiusRestApiManager(logger=mock_logger)
should_retry = api.send_odp_events(api_key=self.api_key,
api_host=self.api_host,
events=self.events)

self.assertFalse(should_retry)
mock_request_post.assert_called_once()
mock_logger.error.assert_called_once_with('ODP event send failed ({"title":"Bad Request","status":400,'
'"timestamp":"2022-07-01T20:44:00.945Z","detail":{"invalids":'
'[{"event":0,"message":"missing \'type\' field"}]}}).')

def test_send_odp_events_500_retry(self):
with mock.patch('requests.post') as mock_request_post, \
mock.patch('optimizely.logger') as mock_logger:
mock_request_post.return_value = self.fake_server_response(status_code=500, url=self.api_host)

api = ZaiusRestApiManager(logger=mock_logger)
should_retry = api.send_odp_events(api_key=self.api_key,
api_host=self.api_host,
events=self.events)

self.assertTrue(should_retry)
mock_request_post.assert_called_once()
mock_logger.error.assert_called_once_with('ODP event send failed (500 Server Error: None for url: test-host).')

# test json responses
success_response_data = '{"title":"Accepted","status":202,"timestamp":"2022-07-01T16:04:06.786Z"}'

failure_response_data = '{"title":"Bad Request","status":400,"timestamp":"2022-07-01T20:44:00.945Z",' \
'"detail":{"invalids":[{"event":0,"message":"missing \'type\' field"}]}}'