From cb4e5c01bbdfb5c92cb9ca27547fd0a058c4dab8 Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Tue, 29 Sep 2026 15:13:27 -0400 Subject: [PATCH 1/5] python cl: add timeout and null condition on mark --- clientlibs/python/README.md | 52 ++++++- .../src/upgrade_client_lib/api_service.py | 17 ++- .../python/src/upgrade_client_lib/client.py | 14 +- clientlibs/python/tests/test_api_service.py | 137 ++++++++++++++++++ clientlibs/python/tests/test_client.py | 71 ++++++++- 5 files changed, 279 insertions(+), 12 deletions(-) diff --git a/clientlibs/python/README.md b/clientlibs/python/README.md index fc8d7f21e..273825753 100644 --- a/clientlibs/python/README.md +++ b/clientlibs/python/README.md @@ -63,7 +63,7 @@ if assignment: ## API Reference -### `UpgradeClient(user_id, host_url, context, token="", client_session_id=None)` +### `UpgradeClient(user_id, host_url, context, token="", client_session_id=None, timeout=5.0)` | Parameter | Type | Description | |---|---|---| @@ -72,6 +72,34 @@ if assignment: | `context` | `str` | Application context string scoping experiment/flag lookups | | `token` | `str` | Optional bearer token for the `Authorization` header | | `client_session_id` | `str \| None` | Optional session ID; auto-generated UUID when omitted | +| `timeout` | `float \| httpx.Timeout` | HTTP request timeout in seconds, or an `httpx.Timeout` object for fine-grained control. Defaults to `5.0` seconds, which matches httpx's own default and preserves the previous implicit behavior. | + +To set a short timeout (e.g. for user-facing synchronous routes where you want fast failure): + +```python +import httpx +from upgrade_client_lib import UpgradeClient + +client = UpgradeClient( + user_id="user-123", + host_url="https://upgrade.example.com", + context="my-app", + timeout=0.5, # 500 ms +) +``` + +For fine-grained control, pass an `httpx.Timeout` object: + +```python +client = UpgradeClient( + user_id="user-123", + host_url="https://upgrade.example.com", + context="my-app", + timeout=httpx.Timeout(connect=1.0, read=5.0, write=5.0, pool=1.0), +) +``` + +The configured timeout applies to both the async (`httpx.AsyncClient`) and sync (`httpx.Client`) HTTP clients. Timeout errors raise `httpx.TimeoutException` and are not caught by the library. Every method has an async variant (no suffix) and a synchronous variant (`_sync` suffix). @@ -92,6 +120,28 @@ Every method has an async variant (no suffix) and a synchronous variant (`_sync` | `get_decision_point_assignment(site, target="")` | Return the `Assignment` for a specific decision point, or `None` | | `mark_decision_point( condition, status, site, target = "", uniquifier="", client_error="")` | Record that the user encountered a decision point | +When no experiment is running at a decision point, mark it with `condition=None` and the `NO_CONDITION_ASSIGNED` status: + +```python +assignment = client.get_decision_point_assignment_sync( + site="problem-info", + target="mathbook_tx", +) + +if assignment is None: + client.mark_decision_point_sync( + condition=None, + status=UpgradeClient.MARKED_DECISION_POINT_STATUS.NO_CONDITION_ASSIGNED, + site="problem-info", + target="mathbook_tx", + ) +else: + condition = assignment.get_condition() + assignment.mark_decision_point_sync( + UpgradeClient.MARKED_DECISION_POINT_STATUS.CONDITION_APPLIED + ) +``` + ### Feature Flags | Method | Description | diff --git a/clientlibs/python/src/upgrade_client_lib/api_service.py b/clientlibs/python/src/upgrade_client_lib/api_service.py index 51a6f266a..f34425d40 100644 --- a/clientlibs/python/src/upgrade_client_lib/api_service.py +++ b/clientlibs/python/src/upgrade_client_lib/api_service.py @@ -20,6 +20,7 @@ ) _API_VERSION = "v6" +_DEFAULT_TIMEOUT = httpx.Timeout(5.0) try: _CLIENT_VERSION = version("upgrade-client-lib") @@ -43,12 +44,14 @@ def __init__( context: str, token: str = "", client_session_id: str = "", + timeout: float | httpx.Timeout = _DEFAULT_TIMEOUT, ) -> None: self._host_url = host_url.rstrip("/") self._user_id = user_id self._context = context self._token = token self._client_session_id = client_session_id + self._timeout = timeout # ------------------------------------------------------------------ # Internal helpers @@ -79,25 +82,25 @@ def _raise_for_status(response: httpx.Response) -> None: ) async def _post_async(self, path: str, body: dict[str, Any]) -> Any: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=self._timeout) as client: response = await client.post(self._url(path), json=body, headers=self._headers()) self._raise_for_status(response) return response.json() async def _patch_async(self, path: str, body: dict[str, Any]) -> Any: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=self._timeout) as client: response = await client.patch(self._url(path), json=body, headers=self._headers()) self._raise_for_status(response) return response.json() def _post_sync(self, path: str, body: dict[str, Any]) -> Any: - with httpx.Client() as client: + with httpx.Client(timeout=self._timeout) as client: response = client.post(self._url(path), json=body, headers=self._headers()) self._raise_for_status(response) return response.json() def _patch_sync(self, path: str, body: dict[str, Any]) -> Any: - with httpx.Client() as client: + with httpx.Client(timeout=self._timeout) as client: response = client.patch(self._url(path), json=body, headers=self._headers()) self._raise_for_status(response) return response.json() @@ -176,7 +179,7 @@ async def mark_decision_point( self, site: str, target: str | None, - condition_code: str, + condition_code: str | None, status: MarkedDecisionPointStatus, experiment_type: ExperimentType | None = None, experiment_id: str | None = None, @@ -204,7 +207,7 @@ def mark_decision_point_sync( self, site: str, target: str | None, - condition_code: str, + condition_code: str | None, status: MarkedDecisionPointStatus, experiment_type: ExperimentType | None = None, experiment_id: str | None = None, @@ -232,7 +235,7 @@ def mark_decision_point_sync( def _build_mark_body( site: str, target: str | None, - condition_code: str, + condition_code: str | None, status: MarkedDecisionPointStatus, experiment_type: ExperimentType | None, experiment_id: str | None, diff --git a/clientlibs/python/src/upgrade_client_lib/client.py b/clientlibs/python/src/upgrade_client_lib/client.py index c1bb28484..097cad447 100644 --- a/clientlibs/python/src/upgrade_client_lib/client.py +++ b/clientlibs/python/src/upgrade_client_lib/client.py @@ -6,7 +6,9 @@ import uuid from typing import Any -from upgrade_client_lib.api_service import ApiService +import httpx + +from upgrade_client_lib.api_service import _DEFAULT_TIMEOUT, ApiService from upgrade_client_lib.assignment import Assignment from upgrade_client_lib.data_service import DataService from upgrade_client_lib.types.enums import BinaryRewardValue, MarkedDecisionPointStatus @@ -41,6 +43,10 @@ class UpgradeClient: client_session_id: Optional session identifier. A UUID is generated automatically when omitted. + timeout: + HTTP request timeout in seconds, or an ``httpx.Timeout`` object for + fine-grained control. Defaults to ``httpx.Timeout(5.0)``, which + matches httpx's own default and preserves the previous implicit behavior. Examples -------- @@ -70,6 +76,7 @@ def __init__( context: str, token: str = "", client_session_id: str | None = None, + timeout: float | httpx.Timeout = _DEFAULT_TIMEOUT, ) -> None: self._user_id = user_id self._context = context @@ -80,6 +87,7 @@ def __init__( context=context, token=token, client_session_id=client_session_id or str(uuid.uuid4()), + timeout=timeout, ) # ------------------------------------------------------------------ @@ -191,7 +199,7 @@ def get_decision_point_assignment_sync( async def mark_decision_point( self, - condition: str, + condition: str | None, status: MarkedDecisionPointStatus, site: str, target: str = "", @@ -241,7 +249,7 @@ async def mark_decision_point( def mark_decision_point_sync( self, - condition: str, + condition: str | None, status: MarkedDecisionPointStatus, site: str, target: str = "", diff --git a/clientlibs/python/tests/test_api_service.py b/clientlibs/python/tests/test_api_service.py index 77fabab29..ff1c685b8 100644 --- a/clientlibs/python/tests/test_api_service.py +++ b/clientlibs/python/tests/test_api_service.py @@ -1,5 +1,8 @@ """Tests for ApiService — all endpoints covered with respx mocks.""" +import unittest.mock as mock + +import httpx import pytest import respx from httpx import Request, Response @@ -479,3 +482,137 @@ def test_repr(self) -> None: err = UpgradeApiError(404, "not found") assert "404" in repr(err) assert "not found" in repr(err) + + +# --------------------------------------------------------------------------- +# Timeout +# --------------------------------------------------------------------------- + + +INIT_PAYLOAD = {"id": USER_ID, "group": None, "workingGroup": None} + + +class TestTimeout: + def test_default_timeout(self) -> None: + svc = make_service() + assert svc._timeout == httpx.Timeout(5.0) + + def test_custom_numeric_timeout_stored(self) -> None: + svc = make_service(timeout=0.5) + assert svc._timeout == 0.5 + + def test_httpx_timeout_object_accepted(self) -> None: + t = httpx.Timeout(connect=1.0, read=2.0, write=2.0, pool=1.0) + svc = make_service(timeout=t) + assert svc._timeout is t + + @respx.mock + async def test_custom_timeout_forwarded_to_async_client(self) -> None: + respx.post(f"{BASE}/init").mock(return_value=Response(200, json=INIT_PAYLOAD)) + svc = make_service(timeout=0.5) + original_init = httpx.AsyncClient.__init__ + + captured: list[object] = [] + + def patched_init(self_inner: object, **kwargs: object) -> None: + captured.append(kwargs.get("timeout")) + original_init(self_inner, **kwargs) # type: ignore[arg-type] + + with mock.patch.object(httpx.AsyncClient, "__init__", patched_init): + await svc.init_user() + + assert captured and captured[0] == 0.5 + + @respx.mock + def test_custom_timeout_forwarded_to_sync_client(self) -> None: + respx.post(f"{BASE}/init").mock(return_value=Response(200, json=INIT_PAYLOAD)) + svc = make_service(timeout=0.5) + original_init = httpx.Client.__init__ + + captured: list[object] = [] + + def patched_init(self_inner: object, **kwargs: object) -> None: + captured.append(kwargs.get("timeout")) + original_init(self_inner, **kwargs) # type: ignore[arg-type] + + with mock.patch.object(httpx.Client, "__init__", patched_init): + svc.init_user_sync() + + assert captured and captured[0] == 0.5 + + @respx.mock + async def test_async_timeout_exception_propagates(self) -> None: + respx.post(f"{BASE}/init").mock(side_effect=httpx.TimeoutException("timed out")) + svc = make_service(timeout=0.001) + with pytest.raises(httpx.TimeoutException): + await svc.init_user() + + @respx.mock + def test_sync_timeout_exception_propagates(self) -> None: + respx.post(f"{BASE}/init").mock(side_effect=httpx.TimeoutException("timed out")) + svc = make_service(timeout=0.001) + with pytest.raises(httpx.TimeoutException): + svc.init_user_sync() + + +# --------------------------------------------------------------------------- +# Nullable condition (mark_decision_point) +# --------------------------------------------------------------------------- + + +class TestMarkDecisionPointNullableCondition: + @respx.mock + async def test_async_accepts_none_condition(self) -> None: + route = respx.post(f"{BASE}/mark").mock(return_value=Response(200, json=MARK_PAYLOAD)) + await make_service().mark_decision_point( + site="problem-info", + target="mathbook_tx", + condition_code=None, + status=MarkedDecisionPointStatus.NO_CONDITION_ASSIGNED, + ) + import json + + body = json.loads(route.calls[0].request.content) + assert body["data"]["assignedCondition"]["conditionCode"] is None + + @respx.mock + def test_sync_accepts_none_condition(self) -> None: + route = respx.post(f"{BASE}/mark").mock(return_value=Response(200, json=MARK_PAYLOAD)) + make_service().mark_decision_point_sync( + site="problem-info", + target="mathbook_tx", + condition_code=None, + status=MarkedDecisionPointStatus.NO_CONDITION_ASSIGNED, + ) + import json + + body = json.loads(route.calls[0].request.content) + assert body["data"]["assignedCondition"]["conditionCode"] is None + + @respx.mock + async def test_none_condition_uses_no_condition_assigned_status(self) -> None: + route = respx.post(f"{BASE}/mark").mock(return_value=Response(200, json=MARK_PAYLOAD)) + await make_service().mark_decision_point( + site="problem-info", + target="mathbook_tx", + condition_code=None, + status=MarkedDecisionPointStatus.NO_CONDITION_ASSIGNED, + ) + import json + + body = json.loads(route.calls[0].request.content) + assert body["status"] == MarkedDecisionPointStatus.NO_CONDITION_ASSIGNED.value + + @respx.mock + async def test_non_null_condition_unchanged(self) -> None: + route = respx.post(f"{BASE}/mark").mock(return_value=Response(200, json=MARK_PAYLOAD)) + await make_service().mark_decision_point( + site="home", + target="banner", + condition_code="control", + status=MarkedDecisionPointStatus.CONDITION_APPLIED, + ) + import json + + body = json.loads(route.calls[0].request.content) + assert body["data"]["assignedCondition"]["conditionCode"] == "control" diff --git a/clientlibs/python/tests/test_client.py b/clientlibs/python/tests/test_client.py index 40eb97850..b4a36736e 100644 --- a/clientlibs/python/tests/test_client.py +++ b/clientlibs/python/tests/test_client.py @@ -6,6 +6,7 @@ import json +import httpx import respx from httpx import Response @@ -350,7 +351,7 @@ async def test_rotation_advances_condition_on_successive_marks(self) -> None: await client.get_all_experiment_conditions() await client.mark_decision_point( - "combo-A", MarkedDecisionPointStatus.CONDITION_APPLIED,"quiz", "hint", + "combo-A", MarkedDecisionPointStatus.CONDITION_APPLIED,"quiz", "hint", ) first_body = json.loads(mark_route.calls[0].request.content) @@ -617,3 +618,71 @@ def test_sync(self) -> None: respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=REWARD_PAYLOAD)) result = make_client().send_reward_sync(BinaryRewardValue.SUCCESS) assert result.message == "ok" + + +# --------------------------------------------------------------------------- +# Timeout +# --------------------------------------------------------------------------- + + +class TestTimeout: + def test_default_timeout_forwarded_to_api_service(self) -> None: + client = make_client() + assert client._api_service._timeout == httpx.Timeout(5.0) + + def test_custom_numeric_timeout_forwarded(self) -> None: + client = make_client(timeout=0.5) + assert client._api_service._timeout == 0.5 + + def test_httpx_timeout_object_accepted(self) -> None: + t = httpx.Timeout(connect=1.0, read=2.0, write=2.0, pool=1.0) + client = make_client(timeout=t) + assert client._api_service._timeout is t + + +# --------------------------------------------------------------------------- +# Nullable condition (mark_decision_point) +# --------------------------------------------------------------------------- + + +class TestMarkDecisionPointNullableCondition: + @respx.mock + async def test_async_accepts_none_condition(self) -> None: + respx.post(f"{BASE}/assign").mock(return_value=Response(200, json=ASSIGNMENT_PAYLOAD)) + mark_route = respx.post(f"{BASE}/mark").mock(return_value=Response(200, json=MARK_PAYLOAD)) + client = make_client() + await client.get_all_experiment_conditions() + await client.mark_decision_point( + None, + MarkedDecisionPointStatus.NO_CONDITION_ASSIGNED, + "problem-info", + "mathbook_tx", + ) + body = json.loads(mark_route.calls[0].request.content) + assert body["data"]["assignedCondition"]["conditionCode"] is None + assert body["status"] == MarkedDecisionPointStatus.NO_CONDITION_ASSIGNED.value + + @respx.mock + def test_sync_accepts_none_condition(self) -> None: + respx.post(f"{BASE}/assign").mock(return_value=Response(200, json=ASSIGNMENT_PAYLOAD)) + mark_route = respx.post(f"{BASE}/mark").mock(return_value=Response(200, json=MARK_PAYLOAD)) + make_client().mark_decision_point_sync( + None, + MarkedDecisionPointStatus.NO_CONDITION_ASSIGNED, + "problem-info", + "mathbook_tx", + ) + body = json.loads(mark_route.calls[0].request.content) + assert body["data"]["assignedCondition"]["conditionCode"] is None + + @respx.mock + async def test_assignment_based_mark_unchanged(self) -> None: + """Assignment.mark_decision_point with a non-null condition is unaffected.""" + respx.post(f"{BASE}/assign").mock(return_value=Response(200, json=ASSIGNMENT_PAYLOAD)) + mark_route = respx.post(f"{BASE}/mark").mock(return_value=Response(200, json=MARK_PAYLOAD)) + client = make_client() + assignments = await client.get_all_experiment_conditions() + assignment = assignments[0] + await assignment.mark_decision_point(MarkedDecisionPointStatus.CONDITION_APPLIED) + body = json.loads(mark_route.calls[0].request.content) + assert body["data"]["assignedCondition"]["conditionCode"] == "control" From 6b4a907120098471b4448bfa1c076374d0c99f78 Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Tue, 29 Sep 2026 15:35:00 -0400 Subject: [PATCH 2/5] add a python quickTest --- clientlibs/python/quickTest.py | 280 +++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 clientlibs/python/quickTest.py diff --git a/clientlibs/python/quickTest.py b/clientlibs/python/quickTest.py new file mode 100644 index 000000000..262641f0c --- /dev/null +++ b/clientlibs/python/quickTest.py @@ -0,0 +1,280 @@ +"""Quick smoke test against a live UpGrade instance. + +Run from the repo root: + python clientlibs/python/quickTest.py + +Or from this directory: + python quickTest.py + +Requires the library to be installed (or on PYTHONPATH). From this directory: + pip install -e . +""" + +import asyncio +import time + +import httpx + +from upgrade_client_lib import UpgradeClient +from upgrade_client_lib.types.requests import LogGroupMetrics, LogInput, LogMetrics + +# --------------------------------------------------------------------------- +# Target URL — swap to point at a different environment +# --------------------------------------------------------------------------- +URL_LOCAL = "http://localhost:3030" +URL_ECS_QA = "https://apps.qa-cli.net/upgrade-service" +URL_ECS_STAGING = "https://apps.qa-cli.com/upgrade-service" + +# --------------------------------------------------------------------------- +# Config — edit these to match your UpGrade setup +# --------------------------------------------------------------------------- +host_url = URL_LOCAL +context = "context_identifier_1" +site = "ts" +target = "" +status = UpgradeClient.MARKED_DECISION_POINT_STATUS.CONDITION_APPLIED +feature_flag_key = "TEST_FEATURE_FLAG" + +user_id = f"quicktest_user_{int(time.time() * 1000)}" +group: dict = {"classId": ["STORED_USER_GROUP"]} +working_group: dict = {"classId": "STORED_USER_GROUP"} +alias = f"alias_{user_id}" + +# Reward testing — set experiment_id to a real Thompson Sampling experiment UUID +# to test send_reward by experiment id. +experiment_id = "PASTE-EXPERIMENT-UUID-HERE" +reward_value = UpgradeClient.BINARY_REWARD_VALUE.SUCCESS + +log_inputs = [ + LogInput( + timestamp="2022-03-03T19:49:00.496", + metrics=LogMetrics( + attributes={ + "totalTimeSeconds": 41834, + "totalMasteryWorkspacesCompleted": 15, + "totalConceptBuildersCompleted": 17, + "totalMasteryWorkspacesGraduated": 15, + "totalSessions": 50, + "totalProblemsCompleted": 249, + }, + groupedMetrics=[ + LogGroupMetrics( + groupClass="conceptBuilderWorkspace", + groupKey="graphs_of_functions", + groupUniquifier="2022-02-03T19:48:53.861Z", + attributes={ + "timeSeconds": 488, + "hintCount": 2, + "errorCount": 15, + "completionCount": 1, + "workspaceCompletionStatus": "GRADUATED", + "problemsCompleted": 4, + }, + ) + ], + ), + ) +] + +# --------------------------------------------------------------------------- +# Main test flow +# --------------------------------------------------------------------------- + + +async def quick_test() -> None: + client = UpgradeClient(user_id=user_id, host_url=host_url, context=context) + + await do_init(client) + await do_group_membership(client) + await do_working_group_membership(client) + await do_aliases(client) + await do_assign(client) + await do_assign_ignore_cache(client) + condition = await do_get_decision_point_assignment(client) + await do_feature_flags(client) + await do_feature_flags_ignore_cache(client) + await do_has_feature_flag(client) + await do_mark(client, condition) + await do_mark_no_assignment(client) + await do_send_reward_by_experiment_id(client) + await do_send_reward_by_decision_point(client) + await do_log(client) + + +# --------------------------------------------------------------------------- +# Individual operations +# --------------------------------------------------------------------------- + + +async def do_init(client: UpgradeClient) -> None: + try: + response = await client.init() + print(f"\n[Init response]: {response}") + except Exception as error: + log_error("Init", error) + + +async def do_group_membership(client: UpgradeClient) -> None: + try: + response = await client.set_group_membership(group) + print(f"\n[Group response]: {response}") + except Exception as error: + log_error("Group", error) + + +async def do_working_group_membership(client: UpgradeClient) -> None: + try: + response = await client.set_working_group(working_group) + print(f"\n[Working Group response]: {response}") + except Exception as error: + log_error("Working Group", error) + + +async def do_aliases(client: UpgradeClient) -> None: + try: + response = await client.set_alt_user_ids([alias]) + print(f"\n[Aliases response]: {response}") + except Exception as error: + log_error("Aliases", error) + + +async def do_assign(client: UpgradeClient) -> None: + try: + response = await client.get_all_experiment_conditions() + print(f"\n[Assign response]: {response}") + except Exception as error: + log_error("Assign", error) + + +async def do_assign_ignore_cache(client: UpgradeClient) -> None: + try: + response = await client.get_all_experiment_conditions(ignore_cache=True) + print(f"\n[Assign (ignore cache) response]: {response}") + except Exception as error: + log_error("Assign (ignore cache)", error) + + +async def do_get_decision_point_assignment(client: UpgradeClient) -> str | None: + try: + assignment = await client.get_decision_point_assignment(site, target) + print(f"\n[Decision Point Assignment response]: {assignment}") + + if assignment is None: + print("\n[Decision Point Assignment]: no assignment found") + return None + + condition = assignment.get_condition() + print(f"\n[Condition]: {condition}") + + exp_type = assignment.get_experiment_type() + print(f"\n[Experiment Type]: {exp_type}") + + payload = assignment.get_payload() + print(f"\n[Payload]: {payload}") + + if payload: + print(f"\n[Payload value]: {payload.value}") + + return condition + except Exception as error: + log_error("Decision Point Assignment", error) + return None + + +async def do_feature_flags(client: UpgradeClient) -> None: + try: + response = await client.get_all_feature_flags() + print(f"\n[Feature Flags response]: {response}") + except Exception as error: + log_error("Feature Flags", error) + + +async def do_feature_flags_ignore_cache(client: UpgradeClient) -> None: + try: + response = await client.get_all_feature_flags(ignore_cache=True) + print(f"\n[Feature Flags (ignore cache) response]: {response}") + except Exception as error: + log_error("Feature Flags (ignore cache)", error) + + +async def do_has_feature_flag(client: UpgradeClient) -> None: + try: + response = await client.has_feature_flag(feature_flag_key) + print(f"\n[Has Feature Flag response]: {response}") + except Exception as error: + log_error("Has Feature Flag", error) + + +async def do_mark(client: UpgradeClient, condition: str | None) -> None: + try: + response = await client.mark_decision_point( + condition=condition, + status=status, + site=site, + target=target, + ) + print(f"\n[Mark response]: {response}") + except Exception as error: + log_error("Mark", error) + + +async def do_mark_no_assignment(client: UpgradeClient) -> None: + """Mark a decision point where no experiment is running (condition=None).""" + try: + response = await client.mark_decision_point( + condition=None, + status=UpgradeClient.MARKED_DECISION_POINT_STATUS.NO_CONDITION_ASSIGNED, + site="nonexistent-site", + target="nonexistent-target", + ) + print(f"\n[Mark (no assignment) response]: {response}") + except Exception as error: + log_error("Mark (no assignment)", error) + + +async def do_send_reward_by_experiment_id(client: UpgradeClient) -> None: + try: + response = await client.send_reward( + reward_value=reward_value, + experiment_id=experiment_id, + ) + print(f"\n[Send Reward (by experiment id) response]: {response}") + except Exception as error: + log_error("Send Reward (by experiment id)", error) + + +async def do_send_reward_by_decision_point(client: UpgradeClient) -> None: + try: + response = await client.send_reward( + reward_value=reward_value, + context=context, + decision_point={"site": site, "target": target}, + ) + print(f"\n[Send Reward (by decision point) response]: {response}") + except Exception as error: + log_error("Send Reward (by decision point)", error) + + +async def do_log(client: UpgradeClient) -> None: + try: + response = await client.log(log_inputs) + print(f"\n[Log response]: {response}") + except Exception as error: + log_error("Log", error) + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + + +def log_error(function_context: str, error: Exception) -> None: + if isinstance(error, httpx.HTTPStatusError): + print(f"\n[{function_context} error]: HTTP {error.response.status_code} — {error.response.text}") + else: + print(f"\n[{function_context} error]: {error}") + + +if __name__ == "__main__": + print(f"\n[quickTest] user_id={user_id} host={host_url} context={context}\n") + asyncio.run(quick_test()) From 92638c64044cd6ea2391c54010b0404b56bf7931 Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Tue, 29 Sep 2026 15:46:05 -0400 Subject: [PATCH 3/5] use UpGradeApiError in quickTest --- clientlibs/python/quickTest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/clientlibs/python/quickTest.py b/clientlibs/python/quickTest.py index 262641f0c..85833707a 100644 --- a/clientlibs/python/quickTest.py +++ b/clientlibs/python/quickTest.py @@ -17,6 +17,7 @@ from upgrade_client_lib import UpgradeClient from upgrade_client_lib.types.requests import LogGroupMetrics, LogInput, LogMetrics +from upgrade_client_lib.exceptions import UpgradeApiError # --------------------------------------------------------------------------- # Target URL — swap to point at a different environment @@ -269,8 +270,8 @@ async def do_log(client: UpgradeClient) -> None: def log_error(function_context: str, error: Exception) -> None: - if isinstance(error, httpx.HTTPStatusError): - print(f"\n[{function_context} error]: HTTP {error.response.status_code} — {error.response.text}") + if isinstance(error, UpgradeApiError): + print(f"\n[{function_context} error]: HTTP {error.status_code} — {error.response_body}") else: print(f"\n[{function_context} error]: {error}") From 136a339156e2013574094a422d9296e44304825a Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Thu, 22 Oct 2026 14:41:05 -0400 Subject: [PATCH 4/5] add awaitResult to reward methods --- clientlibs/python/README.md | 8 ++- .../src/upgrade_client_lib/api_service.py | 16 +++-- .../python/src/upgrade_client_lib/client.py | 12 +++- .../src/upgrade_client_lib/types/responses.py | 4 +- clientlibs/python/tests/test_api_service.py | 48 +++++++++++++++ clientlibs/python/tests/test_client.py | 60 +++++++++++++++++++ 6 files changed, 138 insertions(+), 10 deletions(-) diff --git a/clientlibs/python/README.md b/clientlibs/python/README.md index 273825753..b34f2012f 100644 --- a/clientlibs/python/README.md +++ b/clientlibs/python/README.md @@ -154,7 +154,13 @@ else: | Method | Description | |---|---| | `log(metrics)` | Send outcome metric events | -| `send_reward(reward_value, experiment_id=None, context=None, decision_point=None)` | Send a binary reward signal for an adaptive experiment | +| `send_reward(reward_value, experiment_id=None, context=None, decision_point=None, await_result=False)` | Send a binary reward signal for an adaptive experiment | + +By default, `send_reward` returns an immediate receipt with `mode="accepted"` and `success=None`; +this does not guarantee the reward was recorded. Pass `await_result=True` to wait for processing: +a recorded reward returns `mode="processed"` and `success=True`, while a reward that cannot be +recorded raises `UpgradeApiError` with `status_code=409`. The same option is available on +`send_reward_sync`. Older servers that omit `mode` are supported with `mode=None`. `log()` accepts a list of `LogInput` objects. `LogInput`, `LogMetrics`, and `LogGroupMetrics` must be imported from `upgrade_client_lib.types`: diff --git a/clientlibs/python/src/upgrade_client_lib/api_service.py b/clientlibs/python/src/upgrade_client_lib/api_service.py index 7cc570534..1a8941bc9 100644 --- a/clientlibs/python/src/upgrade_client_lib/api_service.py +++ b/clientlibs/python/src/upgrade_client_lib/api_service.py @@ -81,9 +81,11 @@ def _raise_for_status(response: httpx.Response) -> None: response_body=response.text, ) - async def _post_async(self, path: str, body: dict[str, Any]) -> Any: + async def _post_async( + self, path: str, body: dict[str, Any], params: dict[str, bool] | None = None + ) -> Any: async with httpx.AsyncClient(timeout=self._timeout) as client: - response = await client.post(self._url(path), json=body, headers=self._headers()) + response = await client.post(self._url(path), json=body, headers=self._headers(), params=params) self._raise_for_status(response) return response.json() @@ -93,9 +95,9 @@ async def _patch_async(self, path: str, body: dict[str, Any]) -> Any: self._raise_for_status(response) return response.json() - def _post_sync(self, path: str, body: dict[str, Any]) -> Any: + def _post_sync(self, path: str, body: dict[str, Any], params: dict[str, bool] | None = None) -> Any: with httpx.Client(timeout=self._timeout) as client: - response = client.post(self._url(path), json=body, headers=self._headers()) + response = client.post(self._url(path), json=body, headers=self._headers(), params=params) self._raise_for_status(response) return response.json() @@ -317,9 +319,10 @@ async def send_reward( experiment_id: str | None = None, context: str | None = None, decision_point: dict[str, str] | None = None, + await_result: bool = False, ) -> SendRewardResponse: body = self._build_reward_body(reward_value, experiment_id, context, decision_point) - data = await self._post_async("reward", body) + data = await self._post_async("reward", body, params={"awaitResult": await_result}) return SendRewardResponse.model_validate(data) def send_reward_sync( @@ -328,9 +331,10 @@ def send_reward_sync( experiment_id: str | None = None, context: str | None = None, decision_point: dict[str, str] | None = None, + await_result: bool = False, ) -> SendRewardResponse: body = self._build_reward_body(reward_value, experiment_id, context, decision_point) - data = self._post_sync("reward", body) + data = self._post_sync("reward", body, params={"awaitResult": await_result}) return SendRewardResponse.model_validate(data) @staticmethod diff --git a/clientlibs/python/src/upgrade_client_lib/client.py b/clientlibs/python/src/upgrade_client_lib/client.py index 097cad447..0c4577ac8 100644 --- a/clientlibs/python/src/upgrade_client_lib/client.py +++ b/clientlibs/python/src/upgrade_client_lib/client.py @@ -337,13 +337,20 @@ async def send_reward( experiment_id: str | None = None, context: str | None = None, decision_point: dict[str, str] | None = None, + await_result: bool = False, ) -> SendRewardResponse: - """Send a binary reward signal for an adaptive experiment.""" + """Send a binary reward signal for an adaptive experiment. + + By default, returns an ``accepted`` receipt before processing finishes. + Pass ``await_result=True`` to wait for a ``processed`` response instead. + A reward that cannot be recorded then raises ``UpgradeApiError`` (HTTP 409). + """ return await self._api_service.send_reward( reward_value=reward_value, experiment_id=experiment_id, context=context, decision_point=decision_point, + await_result=await_result, ) def send_reward_sync( @@ -352,8 +359,9 @@ def send_reward_sync( experiment_id: str | None = None, context: str | None = None, decision_point: dict[str, str] | None = None, + await_result: bool = False, ) -> SendRewardResponse: """Synchronous variant of :meth:`send_reward`.""" return asyncio.run( - self.send_reward(reward_value, experiment_id, context, decision_point) + self.send_reward(reward_value, experiment_id, context, decision_point, await_result=await_result) ) diff --git a/clientlibs/python/src/upgrade_client_lib/types/responses.py b/clientlibs/python/src/upgrade_client_lib/types/responses.py index 174f864d6..0a3ac8409 100644 --- a/clientlibs/python/src/upgrade_client_lib/types/responses.py +++ b/clientlibs/python/src/upgrade_client_lib/types/responses.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal from pydantic import BaseModel @@ -77,6 +77,8 @@ class RewardRequest(BaseModel): class SendRewardResponse(BaseModel): message: str request: RewardRequest + mode: Literal["accepted", "processed"] | None = None + success: bool | None = None class ErrorResponse(BaseModel): diff --git a/clientlibs/python/tests/test_api_service.py b/clientlibs/python/tests/test_api_service.py index 30cf20459..0cd80858d 100644 --- a/clientlibs/python/tests/test_api_service.py +++ b/clientlibs/python/tests/test_api_service.py @@ -418,6 +418,54 @@ async def test_sends_aliases(self) -> None: class TestSendReward: + @pytest.mark.parametrize("await_result", [False, True]) + @respx.mock + async def test_async_response_modes(self, await_result: bool) -> None: + payload = {**REWARD_RESPONSE, "mode": "processed" if await_result else "accepted"} + if await_result: + payload["success"] = True + route = respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=payload)) + result = await make_service().send_reward(BinaryRewardValue.SUCCESS, await_result=await_result) + assert route.calls[0].request.url.params["awaitResult"] == str(await_result).lower() + assert b"awaitResult" not in route.calls[0].request.content + assert result.mode == payload["mode"] + assert result.success is (True if await_result else None) + + @pytest.mark.parametrize("await_result", [False, True]) + @respx.mock + def test_sync_response_modes(self, await_result: bool) -> None: + payload = {**REWARD_RESPONSE, "mode": "processed" if await_result else "accepted"} + if await_result: + payload["success"] = True + route = respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=payload)) + result = make_service().send_reward_sync(BinaryRewardValue.SUCCESS, await_result=await_result) + assert route.calls[0].request.url.params["awaitResult"] == str(await_result).lower() + assert b"awaitResult" not in route.calls[0].request.content + assert result.mode == payload["mode"] + assert result.success is (True if await_result else None) + + @respx.mock + async def test_async_await_result_conflict(self) -> None: + respx.post(f"{BASE}/reward", params={"awaitResult": "true"}).mock( + return_value=Response(409, json={"message": "No matching enrollment"}) + ) + service = make_service() + with pytest.raises(UpgradeApiError) as exc_info: + await service.send_reward(BinaryRewardValue.SUCCESS, await_result=True) + assert exc_info.value.status_code == 409 + assert "No matching enrollment" in exc_info.value.response_body + + @respx.mock + def test_sync_await_result_conflict(self) -> None: + respx.post(f"{BASE}/reward", params={"awaitResult": "true"}).mock( + return_value=Response(409, json={"message": "No matching enrollment"}) + ) + service = make_service() + with pytest.raises(UpgradeApiError) as exc_info: + service.send_reward_sync(BinaryRewardValue.SUCCESS, await_result=True) + assert exc_info.value.status_code == 409 + assert "No matching enrollment" in exc_info.value.response_body + @respx.mock async def test_async_minimal(self) -> None: respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=REWARD_RESPONSE)) diff --git a/clientlibs/python/tests/test_client.py b/clientlibs/python/tests/test_client.py index b4a36736e..da869a647 100644 --- a/clientlibs/python/tests/test_client.py +++ b/clientlibs/python/tests/test_client.py @@ -7,10 +7,12 @@ import json import httpx +import pytest import respx from httpx import Response from upgrade_client_lib.client import UpgradeClient +from upgrade_client_lib.exceptions import UpgradeApiError from upgrade_client_lib.types.enums import BinaryRewardValue, ExperimentType, MarkedDecisionPointStatus from upgrade_client_lib.types.requests import LogInput, LogMetrics @@ -591,6 +593,64 @@ def test_sync(self) -> None: class TestSendReward: + @respx.mock + async def test_async_accepted_by_default(self) -> None: + payload = {**REWARD_PAYLOAD, "mode": "accepted"} + route = respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=payload)) + result = await make_client().send_reward(BinaryRewardValue.SUCCESS) + assert route.calls[0].request.url.params["awaitResult"] == "false" + assert result.mode == "accepted" + assert result.success is None + + @respx.mock + def test_sync_accepted_by_default(self) -> None: + payload = {**REWARD_PAYLOAD, "mode": "accepted"} + route = respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=payload)) + result = make_client().send_reward_sync(BinaryRewardValue.SUCCESS) + assert route.calls[0].request.url.params["awaitResult"] == "false" + assert result.mode == "accepted" + assert result.success is None + + @respx.mock + async def test_async_processed(self) -> None: + payload = {**REWARD_PAYLOAD, "mode": "processed", "success": True} + route = respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=payload)) + result = await make_client().send_reward(BinaryRewardValue.SUCCESS, await_result=True) + assert route.calls[0].request.url.params["awaitResult"] == "true" + assert result.mode == "processed" + assert result.success is True + + @respx.mock + def test_sync_processed(self) -> None: + payload = {**REWARD_PAYLOAD, "mode": "processed", "success": True} + route = respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=payload)) + result = make_client().send_reward_sync(BinaryRewardValue.SUCCESS, await_result=True) + assert route.calls[0].request.url.params["awaitResult"] == "true" + assert result.mode == "processed" + assert result.success is True + + @respx.mock + async def test_async_await_result_conflict(self) -> None: + respx.post(f"{BASE}/reward", params={"awaitResult": "true"}).mock( + return_value=Response(409, json={"message": "No matching enrollment"}) + ) + client = make_client() + with pytest.raises(UpgradeApiError) as exc_info: + await client.send_reward(BinaryRewardValue.SUCCESS, await_result=True) + assert exc_info.value.status_code == 409 + assert "No matching enrollment" in exc_info.value.response_body + + @respx.mock + def test_sync_await_result_conflict(self) -> None: + respx.post(f"{BASE}/reward", params={"awaitResult": "true"}).mock( + return_value=Response(409, json={"message": "No matching enrollment"}) + ) + client = make_client() + with pytest.raises(UpgradeApiError) as exc_info: + client.send_reward_sync(BinaryRewardValue.SUCCESS, await_result=True) + assert exc_info.value.status_code == 409 + assert "No matching enrollment" in exc_info.value.response_body + @respx.mock async def test_async_success(self) -> None: respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=REWARD_PAYLOAD)) From 20a272a3d69d4b77f73f60920c8257da1a96d220 Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Thu, 22 Oct 2026 14:48:26 -0400 Subject: [PATCH 5/5] fix mark request type --- clientlibs/python/quickTest.py | 2 ++ .../src/upgrade_client_lib/types/requests.py | 2 +- clientlibs/python/tests/test_types.py | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/clientlibs/python/quickTest.py b/clientlibs/python/quickTest.py index 85833707a..5124b024c 100644 --- a/clientlibs/python/quickTest.py +++ b/clientlibs/python/quickTest.py @@ -10,6 +10,8 @@ pip install -e . """ +from __future__ import annotations + import asyncio import time diff --git a/clientlibs/python/src/upgrade_client_lib/types/requests.py b/clientlibs/python/src/upgrade_client_lib/types/requests.py index 70fcc93f7..a0367e2e8 100644 --- a/clientlibs/python/src/upgrade_client_lib/types/requests.py +++ b/clientlibs/python/src/upgrade_client_lib/types/requests.py @@ -31,7 +31,7 @@ class AssignRequest(BaseModel): class MarkDecisionPointCondition(BaseModel): - conditionCode: str + conditionCode: str | None experimentId: str | None = None diff --git a/clientlibs/python/tests/test_types.py b/clientlibs/python/tests/test_types.py index 4cf6aa471..8ccb5b98d 100644 --- a/clientlibs/python/tests/test_types.py +++ b/clientlibs/python/tests/test_types.py @@ -1,5 +1,7 @@ """Tests for enums, request models, and response models (Phase 2).""" +import json + import pytest from pydantic import ValidationError @@ -115,6 +117,22 @@ def test_valid(self) -> None: class TestMarkDecisionPointRequest: + def test_null_condition_code_json_round_trip(self) -> None: + payload = { + "context": "my-app", + "status": "no condition assigned", + "data": { + "site": "home", + "target": "banner", + "assignedCondition": {"conditionCode": None}, + }, + } + req = MarkDecisionPointRequest.model_validate_json(json.dumps(payload)) + assert req.data.assignedCondition.conditionCode is None + serialized = json.loads(req.model_dump_json(exclude_unset=True)) + assert serialized == payload + assert "conditionCode" in serialized["data"]["assignedCondition"] + def test_valid_minimal(self) -> None: req = MarkDecisionPointRequest( context="my-app",