Skip to content

Add configurable HTTP timeout and nullable mark condition support to the Python client #3301

Description

@zackcl

Background

We are planning to migrate LiveHint AI from direct UpGrade v6 API requests to the Python client introduced in #3146.

LiveHint currently calls the following endpoints directly:

  • POST /api/v6/init
  • POST /api/v6/assign
  • POST /api/v6/mark
  • POST /api/v6/reward

Most of these calls map cleanly to the Python client. However, two client limitations prevent us from preserving the existing application behavior:

  1. The Python client does not expose an HTTP timeout configuration.
  2. UpgradeClient.mark_decision_point() requires condition: str, even though a missing assignment should be marked with a null condition.

1. Add configurable HTTP timeout support

Current behavior

ApiService creates new httpx.Client and httpx.AsyncClient instances without specifying a timeout:

async with httpx.AsyncClient() as client:
    ...

with httpx.Client() as client:
    ...

As a result, the client uses httpx's default timeout.

LiveHint currently uses a 0.5-second timeout for UpGrade requests. Migrating without configurable timeout support would increase the failure-path latency from approximately 0.5 seconds to the httpx default.

Because these calls occur synchronously in user-facing Flask routes, this would be a user-visible behavioral change.

Proposed API

Add an optional timeout to UpgradeClient:

client = UpgradeClient(
    user_id="user-123",
    host_url="https://upgrade.example.com",
    context="livehint-ai",
    timeout=0.5,
)

Suggested constructor signature:

def __init__(
    self,
    user_id: str,
    host_url: str,
    context: str,
    token: str = "",
    client_session_id: str | None = None,
    timeout: float | httpx.Timeout = 5.0,
) -> None:

The value should be passed to ApiService and used by both HTTP clients:

async with httpx.AsyncClient(timeout=self._timeout) as client:
    ...

with httpx.Client(timeout=self._timeout) as client:
    ...

The default should preserve the current effective behavior for existing Python client consumers.

2. Allow a null condition when marking no assignment

Current behavior

The backend and JavaScript client support marking a decision point without a condition when no experiment assignment exists.

Conceptually, the request is:

{
  "status": "no condition assigned",
  "data": {
    "site": "problem-info",
    "target": "mathbook_tx",
    "assignedCondition": {
      "conditionCode": null
    }
  }
}

The Python client currently declares condition and condition_code as str in:

  • UpgradeClient.mark_decision_point
  • UpgradeClient.mark_decision_point_sync
  • ApiService.mark_decision_point
  • ApiService.mark_decision_point_sync
  • ApiService._build_mark_body

This makes the following valid use case incompatible with the public type contract:

client.mark_decision_point_sync(
    condition=None,
    status=UpgradeClient.MARKED_DECISION_POINT_STATUS.NO_CONDITION_ASSIGNED,
    site="problem-info",
    target="mathbook_tx",
)

Although None may currently pass through at runtime, callers should not need to violate the documented type contract.

Proposed change

Update the relevant public and internal method signatures to accept:

condition: str | None

and:

condition_code: str | None

When the condition is None, the serialized request should send:

"assignedCondition": {
  "conditionCode": null
}

This should match the backend and JavaScript client behavior for NO_CONDITION_ASSIGNED.

Tests

Please add coverage for the following cases.

Timeout

  • A custom numeric timeout is forwarded to httpx.AsyncClient.
  • A custom numeric timeout is forwarded to httpx.Client.
  • An httpx.Timeout object is accepted.
  • Omitting the timeout preserves the current default behavior.
  • Timeout exceptions continue to propagate consistently from sync and async APIs.

Nullable condition

  • Async mark_decision_point accepts condition=None.
  • Sync mark_decision_point_sync accepts condition=None.
  • The request contains "conditionCode": null.
  • The request uses the NO_CONDITION_ASSIGNED status.
  • Normal non-null condition marking remains unchanged.
  • Assignment-based marking through Assignment.mark_decision_point remains unchanged.

Documentation

Update the Python client README to document:

  • The new optional timeout constructor parameter.
  • A no-assignment marking example using condition=None.
  • The default timeout behavior.

Example:

client = UpgradeClient(
    user_id="user-123",
    host_url="https://upgrade.example.com",
    context="my-app",
    timeout=0.5,
)

client.init_sync()

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",
    )

Acceptance criteria

  • UpgradeClient exposes a backward-compatible timeout option.
  • The configured timeout is used by both synchronous and asynchronous HTTP requests.
  • Public mark methods accept None as the condition.
  • A null condition is serialized correctly for NO_CONDITION_ASSIGNED.
  • Existing client behavior remains backward compatible.
  • Python client unit tests, type checking, and linting pass.
  • The changes are included in a published PyPI patch release so downstream applications can pin the fixed version.

Downstream use case

Once released, LiveHint AI will use the client for the complete flow:

init → get decision-point assignment → validate payload → mark

It will also replace its direct reward request with send_reward_sync().

Configurable timeout and nullable-condition support will allow that migration without changing LiveHint's existing failure latency or no-assignment telemetry.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions