MemPalace/mempalace · error · ValueError

timeout_ms must be a non-negative number

Error message

timeout_ms must be a non-negative number

What it means

wait_events requires timeout_ms to be an int or float >= 0. Negative timeouts, strings like '30000', and None all fail. Valid values are clamped to MAX_WAIT_TIMEOUT_MS = 300,000 (5 minutes); timeout_ms=0 is legal and degenerates to a single immediate poll that returns timed_out=True when nothing matches. Note the function returns {'timed_out': True, 'events': []} on expiry instead of raising.

Source

Thrown at mempalace/logstream.py:783

    def wait_events(
        self,
        timeout_ms: int = 60_000,
        poll_interval_s: float = None,
        **filters,
    ) -> dict:
        """Block until at least one matching event exists or timeout expires.

        v1 implementation per RFC 003: a polling loop inside the request,
        sleeping 250-1000 ms with jitter. Timeouts are clamped to
        ``MAX_WAIT_TIMEOUT_MS`` and return ``{"timed_out": True,
        "events": []}`` rather than raising.

        ``filters`` accepts the same keyword filters as :meth:`list_events`.
        ``poll_interval_s`` pins the sleep (tests); default is jittered.
        """
        if not isinstance(timeout_ms, (int, float)) or timeout_ms < 0:
            raise ValueError("timeout_ms must be a non-negative number")
        timeout_ms = min(timeout_ms, MAX_WAIT_TIMEOUT_MS)
        deadline = time.monotonic() + timeout_ms / 1000.0

        attempt = 0
        while True:
            events = self.list_events(**filters)
            if events:
                return {"timed_out": False, "events": events}
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                return {"timed_out": True, "events": []}
            if poll_interval_s is not None:
                delay = poll_interval_s
            else:
                base = min(_POLL_MAX_S, _POLL_BASE_S * (1.5**attempt))
                delay = base * (0.75 + random.random() * 0.25)
            time.sleep(min(delay, remaining))
            attempt += 1

View on GitHub (pinned to 06cb6987f0)

Solutions

  1. Pass milliseconds as a number: timeout_ms=300_000 for the 5-minute cap.
  2. For longer waits, loop over wait_events results and accumulate, since anything above 300,000 is clamped anyway.
  3. Coerce external input: timeout_ms=max(0, int(float(raw))) and reject non-numeric strings at the boundary.

Example fix

// before
result = ls.wait_events(correlation_id="task_123", type="patch.ready", timeout_ms=None)
// after
result = ls.wait_events(correlation_id="task_123", type="patch.ready", timeout_ms=300_000)
Defensive patterns

Strategy: validation

Validate before calling

def safe_timeout_ms(value, default=30_000, cap=300_000):
    if value is None:
        return default
    value = float(value)
    if value < 0:
        raise ValueError("timeout must be >= 0")
    return min(int(value), cap)

timeout_ms = safe_timeout_ms(raw_timeout)

Type guard

def is_valid_timeout_ms(v) -> bool:
    return isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 0

Try / catch

try:
    result = ls.wait_events(correlation_id=cid, type=t, timeout_ms=timeout_ms)
except ValueError as e:
    if "timeout_ms" in str(e):
        result = ls.wait_events(correlation_id=cid, type=t, timeout_ms=30_000)
    else:
        raise

Prevention

When it happens

Trigger: wait_events(..., timeout_ms=-1); timeout_ms=None; timeout_ms='5m' from user input; passing seconds (0.3) where milliseconds (300) was intended, producing near-instant timeouts.

Common situations: Config values parsed as strings; unit confusion between seconds and milliseconds; callers treating None as 'wait forever' — the API has no forever mode, it caps at 5 minutes.

Understand the failure class

Related errors


AI-assisted analysis of MemPalace/mempalace@06cb6987f0 (2026-08-15). Data as JSON: /api/errors/901b6f66662869c0. Report an issue: GitHub.