From 09f1e55fcbf3699c4527c9588bf4422a1b7bbb04 Mon Sep 17 00:00:00 2001 From: Alan Gomes <1418294+alangalvino@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:09:22 -0300 Subject: [PATCH 1/2] Add X-Incognia-Latency header with previous request latency --- incognia/api.py | 50 +++++++++++++++++++++++++------- tests/test_api.py | 73 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/incognia/api.py b/incognia/api.py index 0c6d026..3ef034e 100644 --- a/incognia/api.py +++ b/incognia/api.py @@ -1,5 +1,7 @@ import datetime as dt -from typing import Optional, List +import time +from threading import Lock +from typing import Optional, List, Any from .datetime_util import has_timezone, datetime_valid from .endpoints import Endpoints @@ -21,10 +23,38 @@ from .base_request import BaseRequest, JSON_CONTENT_HEADER +LATENCY_HEADER = 'X-Incognia-Latency' +_UNSET = object() + + class IncogniaAPI(metaclass=Singleton): def __init__(self, client_id: str, client_secret: str): self.__token_manager = TokenManager(client_id, client_secret) self.__request = BaseRequest() + self.__last_latency_ms: Optional[int] = None + self.__last_latency_mutex = Lock() + + def __get_last_latency(self) -> Optional[int]: + with self.__last_latency_mutex: + return self.__last_latency_ms + + def __set_last_latency(self, latency_ms: int) -> None: + with self.__last_latency_mutex: + self.__last_latency_ms = latency_ms + + def __post(self, url: str, headers: dict, data: bytes, params: Any = _UNSET): + request_headers = dict(headers) + last_latency = self.__get_last_latency() + if last_latency is not None: + request_headers[LATENCY_HEADER] = str(last_latency) + + start = time.monotonic() + if params is _UNSET: + response = self.__request.post(url, headers=request_headers, data=data) + else: + response = self.__request.post(url, headers=request_headers, data=data, params=params) + self.__set_last_latency(int((time.monotonic() - start) * 1000)) + return response def __get_authorization_header(self) -> dict: access_token, token_type = self.__token_manager.get() @@ -66,7 +96,7 @@ def register_new_signup(self, 'custom_properties': custom_properties } data = encode(body) - return self.__request.post(Endpoints.SIGNUPS, headers=headers, data=data) + return self.__post(Endpoints.SIGNUPS, headers=headers, data=data) except IncogniaHTTPError as e: raise IncogniaHTTPError(e) from None @@ -93,7 +123,7 @@ def register_new_web_signup(self, 'person_id': person_id, } data = encode(body) - return self.__request.post(Endpoints.SIGNUPS, headers=headers, data=data) + return self.__post(Endpoints.SIGNUPS, headers=headers, data=data) except IncogniaHTTPError as e: raise IncogniaHTTPError(e) from None @@ -136,7 +166,7 @@ def register_feedback(self, if expires_at is not None: body['expires_at'] = expires_at.isoformat() data = encode(body) - return self.__request.post(Endpoints.FEEDBACKS, headers=headers, data=data) + return self.__post(Endpoints.FEEDBACKS, headers=headers, data=data) except IncogniaHTTPError as e: raise IncogniaHTTPError(e) from None @@ -202,8 +232,8 @@ def register_payment(self, 'related_web_request_token': related_web_request_token, } data = encode(body) - return self.__request.post(Endpoints.TRANSACTIONS, headers=headers, params=params, - data=data) + return self.__post(Endpoints.TRANSACTIONS, headers=headers, params=params, + data=data) except IncogniaHTTPError as e: raise IncogniaHTTPError(e) from None @@ -256,8 +286,8 @@ def register_login(self, 'related_web_request_token': related_web_request_token, } data = encode(body) - return self.__request.post(Endpoints.TRANSACTIONS, headers=headers, params=params, - data=data) + return self.__post(Endpoints.TRANSACTIONS, headers=headers, params=params, + data=data) except IncogniaHTTPError as e: raise IncogniaHTTPError(e) from None @@ -291,8 +321,8 @@ def register_web_login(self, 'tenant_id': tenant_id, } data = encode(body) - return self.__request.post(Endpoints.TRANSACTIONS, headers=headers, params=params, - data=data) + return self.__post(Endpoints.TRANSACTIONS, headers=headers, params=params, + data=data) except IncogniaHTTPError as e: raise IncogniaHTTPError(e) from None diff --git a/tests/test_api.py b/tests/test_api.py index eee298e..f31021a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,11 +3,12 @@ from unittest import TestCase from unittest.mock import patch, Mock -from incognia.api import IncogniaAPI +from incognia.api import IncogniaAPI, LATENCY_HEADER from incognia.base_request import BaseRequest from incognia.endpoints import Endpoints from incognia.exceptions import IncogniaHTTPError, IncogniaError from incognia.json_util import encode +from incognia.singleton import Singleton from incognia.token_manager import TokenValues, TokenManager @@ -283,12 +284,82 @@ class TestIncogniaAPI(TestCase): }) DEFAULT_PARAMS: Final[None] = None + def setUp(self): + Singleton._instances.pop(IncogniaAPI, None) + def test_metaclass_singleton_should_always_return_the_same_instance(self): api1 = IncogniaAPI(self.CLIENT_ID, self.CLIENT_SECRET) api2 = IncogniaAPI(self.CLIENT_ID, self.CLIENT_SECRET) self.assertEqual(api1, api2) + @patch.object(BaseRequest, 'post') + @patch.object(TokenManager, 'get', return_value=TOKEN_VALUES) + def test_register_new_signup_should_not_send_latency_header_on_first_api_call( + self, mock_token_manager_get: Mock, mock_base_request_post: Mock): + mock_base_request_post.configure_mock(return_value=self.JSON_RESPONSE) + + api = IncogniaAPI(self.CLIENT_ID, self.CLIENT_SECRET) + response = api.register_new_signup(request_token=self.REQUEST_TOKEN) + + mock_token_manager_get.assert_called_once() + headers = mock_base_request_post.call_args.kwargs['headers'] + self.assertNotIn(LATENCY_HEADER, headers) + self.assertEqual(response, self.JSON_RESPONSE) + + @patch.object(BaseRequest, 'post') + @patch.object(TokenManager, 'get', return_value=TOKEN_VALUES) + def test_register_new_signup_should_send_latency_header_on_second_api_call( + self, mock_token_manager_get: Mock, mock_base_request_post: Mock): + mock_base_request_post.configure_mock(return_value=self.JSON_RESPONSE) + + api = IncogniaAPI(self.CLIENT_ID, self.CLIENT_SECRET) + api.register_new_signup(request_token=self.REQUEST_TOKEN) + response = api.register_new_signup(request_token=self.REQUEST_TOKEN) + + self.assertEqual(mock_token_manager_get.call_count, 2) + first_headers = mock_base_request_post.call_args_list[0].kwargs['headers'] + second_headers = mock_base_request_post.call_args_list[1].kwargs['headers'] + self.assertNotIn(LATENCY_HEADER, first_headers) + self.assertIn(LATENCY_HEADER, second_headers) + self.assertGreaterEqual(int(second_headers[LATENCY_HEADER]), 0) + self.assertEqual(response, self.JSON_RESPONSE) + + @patch.object(BaseRequest, 'post') + @patch.object(TokenManager, 'get', return_value=TOKEN_VALUES) + def test_register_feedback_should_send_latency_header_after_payment( + self, mock_token_manager_get: Mock, mock_base_request_post: Mock): + mock_base_request_post.configure_mock(side_effect=[self.JSON_RESPONSE, None]) + + api = IncogniaAPI(self.CLIENT_ID, self.CLIENT_SECRET) + api.register_payment(self.REQUEST_TOKEN, self.ACCOUNT_ID) + api.register_feedback(self.VALID_EVENT_FEEDBACK_TYPE) + + self.assertEqual(mock_token_manager_get.call_count, 2) + payment_headers = mock_base_request_post.call_args_list[0].kwargs['headers'] + feedback_headers = mock_base_request_post.call_args_list[1].kwargs['headers'] + self.assertNotIn(LATENCY_HEADER, payment_headers) + self.assertIn(LATENCY_HEADER, feedback_headers) + self.assertGreaterEqual(int(feedback_headers[LATENCY_HEADER]), 0) + + @patch.object(BaseRequest, 'post') + @patch.object(TokenManager, 'get', return_value=TOKEN_VALUES) + def test_register_new_signup_should_send_latency_header_after_feedback( + self, mock_token_manager_get: Mock, mock_base_request_post: Mock): + mock_base_request_post.configure_mock(side_effect=[None, self.JSON_RESPONSE]) + + api = IncogniaAPI(self.CLIENT_ID, self.CLIENT_SECRET) + api.register_feedback(self.VALID_EVENT_FEEDBACK_TYPE) + response = api.register_new_signup(request_token=self.REQUEST_TOKEN) + + self.assertEqual(mock_token_manager_get.call_count, 2) + feedback_headers = mock_base_request_post.call_args_list[0].kwargs['headers'] + signup_headers = mock_base_request_post.call_args_list[1].kwargs['headers'] + self.assertNotIn(LATENCY_HEADER, feedback_headers) + self.assertIn(LATENCY_HEADER, signup_headers) + self.assertGreaterEqual(int(signup_headers[LATENCY_HEADER]), 0) + self.assertEqual(response, self.JSON_RESPONSE) + @patch.object(BaseRequest, 'post') @patch.object(TokenManager, 'get', return_value=TOKEN_VALUES) def test_register_new_signup_when_request_token_is_valid_should_return_a_valid_dict( From af33dd57503f5cc03c6f9d33ba9c4e46250bbee0 Mon Sep 17 00:00:00 2001 From: Alan Gomes <1418294+alangalvino@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:44:56 -0300 Subject: [PATCH 2/2] Simplify IncogniaAPI post param handling --- incognia/api.py | 11 ++++------- tests/test_api.py | 50 ++++++++++++++++++++++++++++------------------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/incognia/api.py b/incognia/api.py index 3ef034e..5de8022 100644 --- a/incognia/api.py +++ b/incognia/api.py @@ -24,7 +24,6 @@ LATENCY_HEADER = 'X-Incognia-Latency' -_UNSET = object() class IncogniaAPI(metaclass=Singleton): @@ -42,17 +41,14 @@ def __set_last_latency(self, latency_ms: int) -> None: with self.__last_latency_mutex: self.__last_latency_ms = latency_ms - def __post(self, url: str, headers: dict, data: bytes, params: Any = _UNSET): + def __post(self, url: str, headers: dict, data: bytes, params: Optional[Any] = None): request_headers = dict(headers) last_latency = self.__get_last_latency() if last_latency is not None: request_headers[LATENCY_HEADER] = str(last_latency) start = time.monotonic() - if params is _UNSET: - response = self.__request.post(url, headers=request_headers, data=data) - else: - response = self.__request.post(url, headers=request_headers, data=data, params=params) + response = self.__request.post(url, headers=request_headers, data=data, params=params) self.__set_last_latency(int((time.monotonic() - start) * 1000)) return response @@ -166,7 +162,8 @@ def register_feedback(self, if expires_at is not None: body['expires_at'] = expires_at.isoformat() data = encode(body) - return self.__post(Endpoints.FEEDBACKS, headers=headers, data=data) + self.__post(Endpoints.FEEDBACKS, headers=headers, data=data) + return None except IncogniaHTTPError as e: raise IncogniaHTTPError(e) from None diff --git a/tests/test_api.py b/tests/test_api.py index f31021a..fecc22c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -372,7 +372,8 @@ def test_register_new_signup_when_request_token_is_valid_should_return_a_valid_d mock_token_manager_get.assert_called() mock_base_request_post.assert_called_with(Endpoints.SIGNUPS, headers=self.AUTH_AND_JSON_CONTENT_HEADERS, - data=self.REGISTER_SIGNUP_DATA) + data=self.REGISTER_SIGNUP_DATA, + params=None) self.assertEqual(response, self.JSON_RESPONSE) @@ -388,7 +389,8 @@ def test_register_new_web_signup_when_request_token_is_valid_should_return_a_val mock_token_manager_get.assert_called() mock_base_request_post.assert_called_with(Endpoints.SIGNUPS, headers=self.AUTH_AND_JSON_CONTENT_HEADERS, - data=self.REGISTER_WEB_SIGNUP_DATA) + data=self.REGISTER_WEB_SIGNUP_DATA, + params=None) self.assertEqual(response, self.JSON_RESPONSE) @@ -416,7 +418,8 @@ def test_register_new_signup_when_request_token_is_valid_should_return_full_vali mock_token_manager_get.assert_called() mock_base_request_post.assert_called_with(Endpoints.SIGNUPS, headers=self.AUTH_AND_JSON_CONTENT_HEADERS, - data=self.FULL_REGISTER_SIGNUP_DATA) + data=self.FULL_REGISTER_SIGNUP_DATA, + params=None) self.assertEqual(response, self.JSON_RESPONSE) @@ -437,7 +440,8 @@ def test_register_new_web_signup_when_request_token_is_valid_should_return_full_ mock_token_manager_get.assert_called() mock_base_request_post.assert_called_with(Endpoints.SIGNUPS, headers=self.AUTH_AND_JSON_CONTENT_HEADERS, - data=self.FULL_REGISTER_WEB_SIGNUP_DATA) + data=self.FULL_REGISTER_WEB_SIGNUP_DATA, + params=None) self.assertEqual(response, self.JSON_RESPONSE) @@ -476,7 +480,8 @@ def test_register_new_signup_when_request_token_is_invalid_should_raise_an_Incog mock_token_manager_get.assert_called() mock_base_request_post.assert_called_with(Endpoints.SIGNUPS, headers=self.AUTH_AND_JSON_CONTENT_HEADERS, - data=self.REGISTER_INVALID_SIGNUP_DATA) + data=self.REGISTER_INVALID_SIGNUP_DATA, + params=None) @patch.object(BaseRequest, 'post') @patch.object(TokenManager, 'get', return_value=TOKEN_VALUES) @@ -492,7 +497,8 @@ def test_register_new_web_signup_if_request_token_is_invalid_should_raise_an_Inc mock_token_manager_get.assert_called() mock_base_request_post.assert_called_with(Endpoints.SIGNUPS, headers=self.AUTH_AND_JSON_CONTENT_HEADERS, - data=self.REGISTER_INVALID_WEB_SIGNUP_DATA) + data=self.REGISTER_INVALID_WEB_SIGNUP_DATA, + params=None) @patch.object(BaseRequest, 'post') @patch.object(TokenManager, 'get', return_value=TOKEN_VALUES) @@ -500,12 +506,14 @@ def test_register_feedback_when_required_fields_are_valid_should_work( self, mock_token_manager_get: Mock, mock_base_request_post: Mock): api = IncogniaAPI(self.CLIENT_ID, self.CLIENT_SECRET) - api.register_feedback(self.VALID_EVENT_FEEDBACK_TYPE) + response = api.register_feedback(self.VALID_EVENT_FEEDBACK_TYPE) mock_token_manager_get.assert_called() mock_base_request_post.assert_called_with(Endpoints.FEEDBACKS, headers=self.AUTH_AND_JSON_CONTENT_HEADERS, - data=self.REGISTER_VALID_FEEDBACK_DATA) + data=self.REGISTER_VALID_FEEDBACK_DATA, + params=None) + self.assertIsNone(response) @patch.object(BaseRequest, 'post') @patch.object(TokenManager, 'get', return_value=TOKEN_VALUES) @@ -513,22 +521,24 @@ def test_register_feedback_when_all_fields_are_valid_should_work( self, mock_token_manager_get: Mock, mock_base_request_post: Mock): api = IncogniaAPI(self.CLIENT_ID, self.CLIENT_SECRET) - api.register_feedback(self.VALID_EVENT_FEEDBACK_TYPE, - occurred_at=self.TIMESTAMP, - expires_at=self.TIMESTAMP, - external_id=self.EXTERNAL_ID, - login_id=self.LOGIN_ID, - payment_id=self.PAYMENT_ID, - signup_id=self.SIGNUP_ID, - account_id=self.ACCOUNT_ID, - installation_id=self.INSTALLATION_ID, - request_token=self.REQUEST_TOKEN, - person_id=self.PERSON_ID) + response = api.register_feedback(self.VALID_EVENT_FEEDBACK_TYPE, + occurred_at=self.TIMESTAMP, + expires_at=self.TIMESTAMP, + external_id=self.EXTERNAL_ID, + login_id=self.LOGIN_ID, + payment_id=self.PAYMENT_ID, + signup_id=self.SIGNUP_ID, + account_id=self.ACCOUNT_ID, + installation_id=self.INSTALLATION_ID, + request_token=self.REQUEST_TOKEN, + person_id=self.PERSON_ID) mock_token_manager_get.assert_called() mock_base_request_post.assert_called_with(Endpoints.FEEDBACKS, headers=self.AUTH_AND_JSON_CONTENT_HEADERS, - data=self.REGISTER_VALID_FEEDBACK_DATA_FULL) + data=self.REGISTER_VALID_FEEDBACK_DATA_FULL, + params=None) + self.assertIsNone(response) @patch.object(BaseRequest, 'post') @patch.object(TokenManager, 'get', return_value=TOKEN_VALUES)