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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions