Skip to content

fix(sdk): a rate-limited bulk wait no longer reads as still uploading (WALM-671) - #1016

Merged
nikola0x0 merged 5 commits into
devfrom
nikola0x0/WALM-671
Sep 25, 2026
Merged

nikola0x0 merged 5 commits into
devfrom
nikola0x0/WALM-671

Conversation

@nikola0x0

Copy link
Copy Markdown
Collaborator

GH #967: rememberBulkAndWait() returned a success-shaped result for a 12-item batch, and none of the 12 facts were ever stored.

Why it happened

The relayer does not drop items partway through a batch. It charges a bulk POST a flat 10 points, checks that once before any job row exists, and answers a clean 429 if the batch does not fit (the 12×5 in the report is not how bulk is charged).

The false success came from the SDK. waitForRememberJobs treated a 429 on /api/remember/bulk/status as transient and retried silently, and status reads count against the same delegate-key budget as writes. If every read was refused, each item ended as timeout / "polling timed out" and the call resolved without throwing, which looks like "still uploading". The same thing happened on dev on 17 Sep and was fixed only in the MCP bridge (remember-status.ts confirming probe). This PR ports that fix to both SDKs.

Changes (TS and Python SDKs)

  • One confirming read. If no status read got through before the deadline, the wait makes one direct read. In TS it is capped at 5s, so it cannot run up to the 30s request timeout past the caller's timeoutMs.
  • Named error. If the confirming read is rate-limited too, the wait throws MemWalRateLimited (TS: status: 429, jobIds, retryAfterSeconds; Python: .job_ids, .retry_after). It no longer returns a result.
  • No false alarms on partial progress. A batch where anything settled or was seen is still returned, never thrown.
  • Clearer per-item errors. An item still pending at the deadline now says its last known status ("still running after …"), that it was missing from the relayer's answer, or that no read got through.
  • Types. Changes are additive only. status stays done | failed | timeout.
  • Docs. The Python docs list MemWalRateLimited and no longer claim 429s are always retried.

Tests

New TS file remember-bulk-rate-limit.test.mjs and a matching Python class. They cover:

TS 143/143, Python test_client.py 56/56.

Out of scope:

Both this PR and #1015 (WALM-670) add ## Unreleased to both changelogs, so whichever merges second needs a trivial rebase.

Closes #967 · WALM-671

@harrymove-ctrl harrymove-ctrl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Head fa9e3c5b.

Addresses GH #967 / WALM-671:

  • When every status read during the wait loop is rate-limited, the SDK makes one confirming probe (capped at 5s in TS) instead of silently resolving with unconfirmed timeout rows.
  • If the probe is also 429'd, it throws MemWalRateLimited (status: 429, with jobIds/job_ids and retryAfterSeconds/retry_after) so callers do not treat an unconfirmed batch as successful.
  • Batches with partial progress still return the result rather than throwing.
  • Unsettled items accurately report whether they were still running, omitted by the relayer, or blocked by refused polls.
  • Verified test suites: TS remember-bulk-rate-limit.test.mjs (4/4) and Python TestBulkWaitUnderRateLimit (3/3). CI is 23/23 green.

Minor Suggestions (non-blocking)

  1. Body fallback parity: In TS, bulkRateLimitedError checks both the Retry-After header and the JSON body (retry_after_seconds). In Python, err.retry_after only reads the header; if a proxy strips it, the wait hint is omitted in Python.
  2. Rebase note: Touches the same bulk wait loop and changelogs as PR #902. Whichever lands second will need a quick rebase.

# Conflicts:
#	packages/python-sdk-memwal/CHANGELOG.md
#	packages/python-sdk-memwal/memwal/client.py
#	packages/sdk/CHANGELOG.md
#	packages/sdk/src/memwal.ts
…y-After is stripped (WALM-671)

MemWalRateLimited.retry_after only read the Retry-After header, so a proxy
that strips it lost the wait hint. Use the shared header-then-body lookup,
matching the TS SDK.
@nikola0x0
nikola0x0 merged commit be0e23f into dev Sep 25, 2026
23 checks passed

@ducnmm ducnmm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Reviewed the current head 8fe01366 (the confirming-read fix plus the later retry_after_seconds commit). The all-429 path now does one confirming status read and raises MemWalRateLimited instead of resolving a batch of timeout rows. Two holes are still on this head.

Issue counts by severity

  • bugs: 2
  • suggestions: 0
  • nits: 0

# Nothing settled and nothing was seen: the polls may all have been
# refused. Confirm with one direct read before reporting anything.
try:
probed = await self.get_remember_bulk_status(list(job_ids))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] This confirming read has no per-request timeout, so it uses the shared httpx.AsyncClient timeout of 60s. The except only handles _HttpStatusError. A stalled socket holds wait_for_remember_jobs up to 60s past the caller's deadline and then raises httpx.ReadTimeout, which carries no job_ids. TypeScript caps the same read at 5s and, on abort, still returns timeout rows that keep the ids.

Suggestion: Pass a 5s timeout into this confirming get_remember_bulk_status only. On that timeout, fall through to the existing "no status read got through" rows instead of letting the httpx error escape.

// Nothing settled and nothing was even seen: the polls may all have been
// refused rather than the jobs being slow. Confirm with one direct read,
// as the MCP bridge does, before reporting anything.
if (timeoutMs > 0 && pending.size === jobIds.length && lastSeen.size === 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] This condition is also true for an empty jobIds list (pending is empty and lastSeen is empty), so waitForRememberJobs([]) now POSTs /api/remember/bulk/status with job_ids: []. The relayer rejects that with 400, but the rate limiter records the weight before the handler runs. A 429 on that probe throws MemWalRateLimited ("none of its 0 writes could be confirmed") for a batch that had nothing to confirm. analyzeAndWait calls this with accepted.job_ids, which is empty when extraction finds no facts. The MCP path this ports guards with rows.length > 0. The same check is at client.py:718.

Suggestion: Probe only when jobIds.length > 0 (and len(job_ids) > 0 in Python). An empty wait should return the empty result immediately.

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

Labels

None yet

Projects

None yet

3 participants