hebbrix 2.4.0__tar.gz → 2.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {hebbrix-2.4.0 → hebbrix-2.4.1}/CHANGELOG.md +8 -0
  2. {hebbrix-2.4.0/hebbrix.egg-info → hebbrix-2.4.1}/PKG-INFO +35 -5
  3. {hebbrix-2.4.0 → hebbrix-2.4.1}/README.md +32 -3
  4. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/__init__.py +1 -1
  5. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/client.py +19 -2
  6. hebbrix-2.4.1/hebbrix/exceptions.py +135 -0
  7. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/resources.py +54 -11
  8. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/sync_client.py +77 -17
  9. {hebbrix-2.4.0 → hebbrix-2.4.1/hebbrix.egg-info}/PKG-INFO +35 -5
  10. {hebbrix-2.4.0 → hebbrix-2.4.1}/pyproject.toml +3 -2
  11. {hebbrix-2.4.0 → hebbrix-2.4.1}/tests/test_memories_resource.py +200 -1
  12. {hebbrix-2.4.0 → hebbrix-2.4.1}/tests/test_openapi_parity.py +0 -50
  13. hebbrix-2.4.1/tests/test_sync_client.py +305 -0
  14. hebbrix-2.4.0/hebbrix/exceptions.py +0 -76
  15. hebbrix-2.4.0/tests/test_sync_client.py +0 -130
  16. {hebbrix-2.4.0 → hebbrix-2.4.1}/LICENSE +0 -0
  17. {hebbrix-2.4.0 → hebbrix-2.4.1}/MANIFEST.in +0 -0
  18. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/chat.py +0 -0
  19. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/models.py +0 -0
  20. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix.egg-info/SOURCES.txt +0 -0
  21. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix.egg-info/dependency_links.txt +0 -0
  22. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix.egg-info/requires.txt +0 -0
  23. {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix.egg-info/top_level.txt +0 -0
  24. {hebbrix-2.4.0 → hebbrix-2.4.1}/setup.cfg +0 -0
  25. {hebbrix-2.4.0 → hebbrix-2.4.1}/tests/test_advanced_resources.py +0 -0
  26. {hebbrix-2.4.0 → hebbrix-2.4.1}/tests/test_procedural_resource.py +0 -0
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.1 — 2026-08-27
4
+
5
+ - Make every async and sync readiness deadline preserve the original durable
6
+ receipt and raise top-level `IndexingTimeoutError`, including single writes,
7
+ batches, inference jobs, and direct updates.
8
+ - Normalize memory/job/status/request/outbox/idempotency recovery metadata and
9
+ preserve transport-only `Location`, request, retry, event, and replay headers.
10
+
3
11
  ## 2.4.0 — 2026-08-27
4
12
 
5
13
  - Reconcile every exported advanced method with the canonical public OpenAPI,
@@ -1,13 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs
5
5
  Author-email: Hebbrix Team <support@hebbrix.com>
6
6
  Maintainer-email: Hebbrix Team <support@hebbrix.com>
7
7
  License-Expression: MIT
8
8
  Project-URL: Homepage, https://hebbrix.com
9
9
  Project-URL: Documentation, https://docs.hebbrix.com
10
- Project-URL: Source, https://pypi.org/project/hebbrix/#files
10
+ Project-URL: Source, https://github.com/Hebbrix/hebbrix-python
11
+ Project-URL: Issues, https://github.com/Hebbrix/hebbrix-python/issues
11
12
  Project-URL: Support, https://www.hebbrix.com/contact
12
13
  Project-URL: API Reference, https://api.hebbrix.com/docs
13
14
  Keywords: ai,memory,agents,llm,chatbot,assistant,ml,reinforcement-learning,knowledge-graph,vector-search,rag,temporal,procedural-memory,working-memory
@@ -46,7 +47,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
46
47
  ## Install
47
48
 
48
49
  ```bash
49
- pip install hebbrix==2.4.0
50
+ pip install hebbrix==2.4.1
50
51
  ```
51
52
 
52
53
  Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
@@ -87,8 +88,37 @@ converging; it is not a failure and does not justify a duplicate write.
87
88
  When `wait_for_index=True`, the SDK accepts that receipt and polls the documented
88
89
  status URL. It returns only after `searchable=true`. If the caller's deadline
89
90
  expires, it raises `IndexingTimeoutError`; the exception retains the original
90
- receipt, durable memory IDs, and status URL. Reuse the same idempotency key with
91
- the same body to recover the same logical resources.
91
+ receipt plus normalized `memory_ids`, `job_id`, `status_url`, `request_id`,
92
+ `outbox_event_id`, retry timing, and idempotency replay metadata when available.
93
+ The synchronous and asynchronous single, batch, inference-job, and update
94
+ readiness paths share this behavior. The SDK never repeats the write while it
95
+ polls.
96
+
97
+ Catch the typed deadline without discarding the durable acceptance:
98
+
99
+ ```python
100
+ from hebbrix import IndexingTimeoutError
101
+
102
+ try:
103
+ created = await client.memories.create(
104
+ content="Customer prefers concise replies",
105
+ wait_for_index=True,
106
+ idempotency_key="customer-42-preference-v1",
107
+ index_timeout=5,
108
+ )
109
+ except IndexingTimeoutError as exc:
110
+ # Resume observation; do not submit an unrelated second write.
111
+ if exc.memory_ids:
112
+ created = await client.memories.wait_until_searchable(exc.memory_ids[0])
113
+ elif exc.job_id:
114
+ created = await client.memory_jobs.wait(exc.job_id)
115
+ ```
116
+
117
+ For a timed-out batch, pass `exc.receipt` to
118
+ `memories.wait_batch_until_searchable(...)`. Alternatively, replay the exact
119
+ same body with `exc.idempotency_key`; a changed body with the same key is
120
+ rejected by the API rather than creating a second logical write. For an update,
121
+ resume polling `exc.memory_ids[0]` because the relational edit already committed.
92
122
 
93
123
  For an asynchronous batch receipt:
94
124
 
@@ -5,7 +5,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- pip install hebbrix==2.4.0
8
+ pip install hebbrix==2.4.1
9
9
  ```
10
10
 
11
11
  Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
@@ -46,8 +46,37 @@ converging; it is not a failure and does not justify a duplicate write.
46
46
  When `wait_for_index=True`, the SDK accepts that receipt and polls the documented
47
47
  status URL. It returns only after `searchable=true`. If the caller's deadline
48
48
  expires, it raises `IndexingTimeoutError`; the exception retains the original
49
- receipt, durable memory IDs, and status URL. Reuse the same idempotency key with
50
- the same body to recover the same logical resources.
49
+ receipt plus normalized `memory_ids`, `job_id`, `status_url`, `request_id`,
50
+ `outbox_event_id`, retry timing, and idempotency replay metadata when available.
51
+ The synchronous and asynchronous single, batch, inference-job, and update
52
+ readiness paths share this behavior. The SDK never repeats the write while it
53
+ polls.
54
+
55
+ Catch the typed deadline without discarding the durable acceptance:
56
+
57
+ ```python
58
+ from hebbrix import IndexingTimeoutError
59
+
60
+ try:
61
+ created = await client.memories.create(
62
+ content="Customer prefers concise replies",
63
+ wait_for_index=True,
64
+ idempotency_key="customer-42-preference-v1",
65
+ index_timeout=5,
66
+ )
67
+ except IndexingTimeoutError as exc:
68
+ # Resume observation; do not submit an unrelated second write.
69
+ if exc.memory_ids:
70
+ created = await client.memories.wait_until_searchable(exc.memory_ids[0])
71
+ elif exc.job_id:
72
+ created = await client.memory_jobs.wait(exc.job_id)
73
+ ```
74
+
75
+ For a timed-out batch, pass `exc.receipt` to
76
+ `memories.wait_batch_until_searchable(...)`. Alternatively, replay the exact
77
+ same body with `exc.idempotency_key`; a changed body with the same key is
78
+ rejected by the API rather than creating a second logical write. For an update,
79
+ resume polling `exc.memory_ids[0]` because the relational edit already committed.
51
80
 
52
81
  For an asynchronous batch receipt:
53
82
 
@@ -5,7 +5,7 @@ production OpenAPI and ``GET /v1/users/me/capabilities``. The experimental
5
5
  World Model is intentionally absent from this release.
6
6
  """
7
7
 
8
- __version__ = "2.4.0"
8
+ __version__ = "2.4.1"
9
9
  __author__ = "Hebbrix Team"
10
10
  __license__ = "MIT"
11
11
 
@@ -90,7 +90,7 @@ class MemoryClient:
90
90
  """Get request headers."""
91
91
  headers = {
92
92
  "Content-Type": "application/json",
93
- "User-Agent": "hebbrix-python/2.4.0",
93
+ "User-Agent": "hebbrix-python/2.4.1",
94
94
  }
95
95
 
96
96
  if self.api_key:
@@ -200,7 +200,24 @@ class MemoryClient:
200
200
  if response.status_code >= 400:
201
201
  self._handle_error(response)
202
202
 
203
- return response.json() if response.text else {}
203
+ payload = response.json() if response.text else {}
204
+ if isinstance(payload, dict):
205
+ # Preserve transport-only recovery identifiers on durable 202
206
+ # receipts. The resource layer needs these values if its local
207
+ # readiness deadline expires after the write has committed.
208
+ recovery_headers = {
209
+ "request_id": response.headers.get("X-Request-ID"),
210
+ "status_url": response.headers.get("Location"),
211
+ "outbox_event_id": response.headers.get("X-Hebbrix-Index-Event"),
212
+ "retry_after": response.headers.get("Retry-After"),
213
+ }
214
+ for key, value in recovery_headers.items():
215
+ if value and not payload.get(key):
216
+ payload[key] = value
217
+ replay = response.headers.get("X-Idempotent-Replay")
218
+ if replay is not None and "idempotency_replay" not in payload:
219
+ payload["idempotency_replay"] = replay.casefold() == "true"
220
+ return payload
204
221
 
205
222
  async def get(self, path: str, **kwargs) -> Dict[str, Any]:
206
223
  """Make a GET request."""
@@ -0,0 +1,135 @@
1
+ """
2
+ Hebbrix SDK Exceptions
3
+ """
4
+
5
+
6
+ class HebbrixError(Exception):
7
+ """Base exception for Hebbrix SDK."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ status_code: int = None,
13
+ *,
14
+ code: str = None,
15
+ request_id: str = None,
16
+ details: dict = None,
17
+ ):
18
+ self.message = message
19
+ self.status_code = status_code
20
+ self.code = code
21
+ self.request_id = request_id
22
+ self.details = details or {}
23
+ super().__init__(self.message)
24
+
25
+
26
+ class EntitlementError(HebbrixError):
27
+ """The authenticated account lacks the plan or role for an operation."""
28
+
29
+ def __init__(self, message: str, *, status_code: int, **kwargs):
30
+ super().__init__(message, status_code=status_code, **kwargs)
31
+
32
+
33
+ class IndexingTimeoutError(TimeoutError):
34
+ """A durable write did not become searchable before the client deadline.
35
+
36
+ The write has already committed when this exception is raised. Recovery
37
+ metadata therefore lives on the exception so callers can resume polling or
38
+ safely replay the *same* request with the same idempotency key instead of
39
+ issuing an uncorrelated duplicate write.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ message: str,
45
+ receipt: dict,
46
+ *,
47
+ idempotency_key: str = None,
48
+ ):
49
+ self.receipt = dict(receipt or {})
50
+
51
+ candidates = [
52
+ *(self.receipt.get("memory_ids") or []),
53
+ self.receipt.get("memory_id"),
54
+ self.receipt.get("id"),
55
+ *(
56
+ item.get("memory_id") or item.get("id")
57
+ for item in (self.receipt.get("results") or [])
58
+ if isinstance(item, dict)
59
+ ),
60
+ ]
61
+ self.memory_ids = list(
62
+ dict.fromkeys(str(value) for value in candidates if value)
63
+ )
64
+ self.job_id = self.receipt.get("job_id")
65
+ self.status_url = self.receipt.get("status_url")
66
+ if not self.status_url and self.memory_ids:
67
+ self.status_url = f"/v1/memories/{self.memory_ids[0]}"
68
+ if not self.status_url and self.job_id:
69
+ self.status_url = f"/v1/memory-jobs/{self.job_id}"
70
+
71
+ self.request_id = self.receipt.get("request_id")
72
+ self.outbox_event_id = self.receipt.get("outbox_event_id")
73
+ self.indexing_event_id = (
74
+ self.receipt.get("indexing_event_id") or self.outbox_event_id
75
+ )
76
+ self.event_id = self.receipt.get("event_id") or self.indexing_event_id
77
+ self.idempotency_key = idempotency_key or self.receipt.get("idempotency_key")
78
+ self.idempotency_replay = self.receipt.get(
79
+ "idempotency_replay",
80
+ self.receipt.get("idempotency_replayed"),
81
+ )
82
+ self.retry_after = self.receipt.get("retry_after")
83
+ self.recovery = {
84
+ key: value
85
+ for key, value in {
86
+ "memory_ids": list(self.memory_ids),
87
+ "job_id": self.job_id,
88
+ "status_url": self.status_url,
89
+ "request_id": self.request_id,
90
+ "outbox_event_id": self.outbox_event_id,
91
+ "indexing_event_id": self.indexing_event_id,
92
+ "event_id": self.event_id,
93
+ "idempotency_key": self.idempotency_key,
94
+ "idempotency_replay": self.idempotency_replay,
95
+ "retry_after": self.retry_after,
96
+ }.items()
97
+ if value not in (None, "", [])
98
+ }
99
+ super().__init__(message)
100
+
101
+
102
+ class AuthenticationError(HebbrixError):
103
+ """Raised when authentication fails."""
104
+
105
+ def __init__(self, message: str = "Authentication failed", **kwargs):
106
+ super().__init__(message, status_code=401, **kwargs)
107
+
108
+
109
+ class ValidationError(HebbrixError):
110
+ """Raised when request validation fails."""
111
+
112
+ def __init__(self, message: str, errors: list = None, **kwargs):
113
+ self.errors = errors or []
114
+ super().__init__(message, status_code=422, **kwargs)
115
+
116
+
117
+ class NotFoundError(HebbrixError):
118
+ """Raised when a resource is not found."""
119
+
120
+ def __init__(self, message: str = "Resource not found", **kwargs):
121
+ super().__init__(message, status_code=404, **kwargs)
122
+
123
+
124
+ class RateLimitError(HebbrixError):
125
+ """Raised when rate limit is exceeded."""
126
+
127
+ def __init__(self, message: str = "Rate limit exceeded", **kwargs):
128
+ super().__init__(message, status_code=429, **kwargs)
129
+
130
+
131
+ class ServerError(HebbrixError):
132
+ """Raised when server returns 5xx error."""
133
+
134
+ def __init__(self, message: str = "Internal server error", **kwargs):
135
+ super().__init__(message, status_code=500, **kwargs)
@@ -457,6 +457,7 @@ class MemoriesResource(BaseResource):
457
457
  receipt,
458
458
  timeout=index_timeout,
459
459
  poll_interval=index_poll_interval,
460
+ idempotency_key=idempotency_key,
460
461
  )
461
462
  return receipt
462
463
 
@@ -505,11 +506,15 @@ class MemoriesResource(BaseResource):
505
506
  if idempotency_key:
506
507
  kwargs["headers"] = {"Idempotency-Key": idempotency_key}
507
508
  receipt = await self.client.post("/v1/memories/batch", **kwargs)
508
- if wait_for_index and not receipt.get("searchable"):
509
+ if wait_for_index and not (
510
+ receipt.get("searchable") is True
511
+ and str(receipt.get("processing_status") or "").casefold() == "completed"
512
+ ):
509
513
  receipt = await self.wait_batch_until_searchable(
510
514
  receipt,
511
515
  timeout=index_timeout,
512
516
  poll_interval=index_poll_interval,
517
+ idempotency_key=idempotency_key,
513
518
  )
514
519
  return receipt
515
520
 
@@ -519,6 +524,7 @@ class MemoriesResource(BaseResource):
519
524
  *,
520
525
  timeout: float = 60.0,
521
526
  poll_interval: float = 0.5,
527
+ idempotency_key: Optional[str] = None,
522
528
  ) -> Dict[str, Any]:
523
529
  """Poll every item in an asynchronous batch receipt to one terminal state.
524
530
 
@@ -537,11 +543,20 @@ class MemoriesResource(BaseResource):
537
543
  )
538
544
  for row in rows:
539
545
  state = str(row.get("processing_status") or "").casefold()
546
+ if state == "completed" and row.get("searchable") is not True:
547
+ raise RuntimeError(
548
+ f"memory {row.get('id')} reported completed without "
549
+ "searchable=true"
550
+ )
540
551
  if state in {"failed", "cancelled", "canceled"}:
541
552
  raise RuntimeError(
542
553
  f"memory {row.get('id')} indexing reached terminal state {state}"
543
554
  )
544
- if all(row.get("searchable") is True for row in rows):
555
+ if all(
556
+ row.get("searchable") is True
557
+ and str(row.get("processing_status") or "").casefold() == "completed"
558
+ for row in rows
559
+ ):
545
560
  return {
546
561
  **receipt,
547
562
  "processing_status": "completed",
@@ -556,10 +571,14 @@ class MemoriesResource(BaseResource):
556
571
  ],
557
572
  }
558
573
  if time.monotonic() >= deadline:
559
- raise IndexingTimeoutError(
574
+ timeout_error = TimeoutError(
560
575
  f"batch was not searchable within {timeout}s; the write is durable",
561
- receipt,
562
576
  )
577
+ raise IndexingTimeoutError(
578
+ str(timeout_error),
579
+ receipt,
580
+ idempotency_key=idempotency_key,
581
+ ) from timeout_error
563
582
  await asyncio.sleep(max(0.05, poll_interval))
564
583
 
565
584
  async def wait_until_searchable(
@@ -597,6 +616,7 @@ class MemoriesResource(BaseResource):
597
616
  *,
598
617
  timeout: float,
599
618
  poll_interval: float,
619
+ idempotency_key: Optional[str] = None,
600
620
  ) -> Dict[str, Any]:
601
621
  if (
602
622
  receipt.get("searchable") is True
@@ -619,9 +639,14 @@ class MemoriesResource(BaseResource):
619
639
  f"memory job {job_id} reached terminal state {state}"
620
640
  )
621
641
  if time.monotonic() >= deadline:
622
- raise TimeoutError(
642
+ timeout_error = TimeoutError(
623
643
  f"memory job {job_id} did not become searchable within {timeout}s"
624
644
  )
645
+ raise IndexingTimeoutError(
646
+ f"{timeout_error}; the write is durable",
647
+ receipt,
648
+ idempotency_key=idempotency_key,
649
+ ) from timeout_error
625
650
  await asyncio.sleep(max(0.05, poll_interval))
626
651
 
627
652
  candidates = [
@@ -637,11 +662,19 @@ class MemoriesResource(BaseResource):
637
662
  raise RuntimeError(
638
663
  "wait_for_index response contained neither a memory id nor a job id"
639
664
  )
640
- ready = await self.wait_until_searchable(
641
- memory_id,
642
- timeout=timeout,
643
- poll_interval=poll_interval,
644
- )
665
+ try:
666
+ ready = await self.wait_until_searchable(
667
+ memory_id,
668
+ timeout=timeout,
669
+ poll_interval=poll_interval,
670
+ )
671
+ except TimeoutError as exc:
672
+ raise IndexingTimeoutError(
673
+ f"memory {memory_id} was not searchable within {timeout}s; "
674
+ "the write is durable",
675
+ receipt,
676
+ idempotency_key=idempotency_key,
677
+ ) from exc
645
678
  receipt["searchable"] = True
646
679
  receipt["processing_status"] = "completed"
647
680
  receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
@@ -793,6 +826,8 @@ class MemoriesResource(BaseResource):
793
826
  importance: Optional[float] = None,
794
827
  metadata: Optional[Dict[str, Any]] = None,
795
828
  wait_for_index: Optional[bool] = None,
829
+ index_timeout: float = 60.0,
830
+ index_poll_interval: float = 0.5,
796
831
  ) -> Dict[str, Any]:
797
832
  """
798
833
  Update a memory.
@@ -816,7 +851,15 @@ class MemoriesResource(BaseResource):
816
851
  if wait_for_index is not None:
817
852
  data["wait_for_index"] = wait_for_index
818
853
 
819
- return await self.client.patch(f"/v1/memories/{memory_id}", json=data)
854
+ receipt = await self.client.patch(f"/v1/memories/{memory_id}", json=data)
855
+ if wait_for_index is True:
856
+ receipt = {**receipt, "id": receipt.get("id") or memory_id}
857
+ receipt = await self._ensure_searchable_receipt(
858
+ receipt,
859
+ timeout=index_timeout,
860
+ poll_interval=index_poll_interval,
861
+ )
862
+ return receipt
820
863
 
821
864
  async def delete(self, memory_id: str) -> None:
822
865
  """
@@ -100,6 +100,7 @@ class SyncMemoriesResource:
100
100
  receipt,
101
101
  timeout=index_timeout,
102
102
  poll_interval=index_poll_interval,
103
+ idempotency_key=idempotency_key,
103
104
  )
104
105
  return receipt
105
106
 
@@ -141,11 +142,15 @@ class SyncMemoriesResource:
141
142
  if idempotency_key:
142
143
  kwargs["headers"] = {"Idempotency-Key": idempotency_key}
143
144
  receipt = self.client.post("/v1/memories/batch", **kwargs)
144
- if wait_for_index and not receipt.get("searchable"):
145
+ if wait_for_index and not (
146
+ receipt.get("searchable") is True
147
+ and str(receipt.get("processing_status") or "").casefold() == "completed"
148
+ ):
145
149
  receipt = self.wait_batch_until_searchable(
146
150
  receipt,
147
151
  timeout=index_timeout,
148
152
  poll_interval=index_poll_interval,
153
+ idempotency_key=idempotency_key,
149
154
  )
150
155
  return receipt
151
156
 
@@ -155,6 +160,7 @@ class SyncMemoriesResource:
155
160
  *,
156
161
  timeout: float = 60.0,
157
162
  poll_interval: float = 0.5,
163
+ idempotency_key: Optional[str] = None,
158
164
  ) -> Dict[str, Any]:
159
165
  """Poll every item in an asynchronous batch receipt until searchable."""
160
166
 
@@ -166,11 +172,20 @@ class SyncMemoriesResource:
166
172
  rows = [self.get(memory_id) for memory_id in memory_ids]
167
173
  for row in rows:
168
174
  state = str(row.get("processing_status") or "").casefold()
175
+ if state == "completed" and row.get("searchable") is not True:
176
+ raise RuntimeError(
177
+ f"memory {row.get('id')} reported completed without "
178
+ "searchable=true"
179
+ )
169
180
  if state in {"failed", "cancelled", "canceled"}:
170
181
  raise RuntimeError(
171
182
  f"memory {row.get('id')} indexing reached terminal state {state}"
172
183
  )
173
- if all(row.get("searchable") is True for row in rows):
184
+ if all(
185
+ row.get("searchable") is True
186
+ and str(row.get("processing_status") or "").casefold() == "completed"
187
+ for row in rows
188
+ ):
174
189
  return {
175
190
  **receipt,
176
191
  "processing_status": "completed",
@@ -185,10 +200,14 @@ class SyncMemoriesResource:
185
200
  ],
186
201
  }
187
202
  if time.monotonic() >= deadline:
188
- raise IndexingTimeoutError(
203
+ timeout_error = TimeoutError(
189
204
  f"batch was not searchable within {timeout}s; the write is durable",
190
- receipt,
191
205
  )
206
+ raise IndexingTimeoutError(
207
+ str(timeout_error),
208
+ receipt,
209
+ idempotency_key=idempotency_key,
210
+ ) from timeout_error
192
211
  time.sleep(max(0.05, poll_interval))
193
212
 
194
213
  def wait_until_searchable(
@@ -224,6 +243,7 @@ class SyncMemoriesResource:
224
243
  *,
225
244
  timeout: float,
226
245
  poll_interval: float,
246
+ idempotency_key: Optional[str] = None,
227
247
  ) -> Dict[str, Any]:
228
248
  if (
229
249
  receipt.get("searchable") is True
@@ -232,11 +252,19 @@ class SyncMemoriesResource:
232
252
  return receipt
233
253
  job_id = str(receipt.get("job_id") or "")
234
254
  if job_id:
235
- job = SyncMemoryJobsResource(self.client).wait(
236
- job_id,
237
- timeout=timeout,
238
- poll_interval=poll_interval,
239
- )
255
+ try:
256
+ job = SyncMemoryJobsResource(self.client).wait(
257
+ job_id,
258
+ timeout=timeout,
259
+ poll_interval=poll_interval,
260
+ )
261
+ except TimeoutError as exc:
262
+ raise IndexingTimeoutError(
263
+ f"memory job {job_id} did not become searchable within {timeout}s; "
264
+ "the write is durable",
265
+ receipt,
266
+ idempotency_key=idempotency_key,
267
+ ) from exc
240
268
  if str(job.get("status") or "").casefold() != "completed":
241
269
  raise RuntimeError(f"memory job {job_id} did not complete")
242
270
  receipt.update(job)
@@ -256,11 +284,19 @@ class SyncMemoriesResource:
256
284
  raise RuntimeError(
257
285
  "wait_for_index response contained neither a memory id nor a job id"
258
286
  )
259
- ready = self.wait_until_searchable(
260
- memory_id,
261
- timeout=timeout,
262
- poll_interval=poll_interval,
263
- )
287
+ try:
288
+ ready = self.wait_until_searchable(
289
+ memory_id,
290
+ timeout=timeout,
291
+ poll_interval=poll_interval,
292
+ )
293
+ except TimeoutError as exc:
294
+ raise IndexingTimeoutError(
295
+ f"memory {memory_id} was not searchable within {timeout}s; "
296
+ "the write is durable",
297
+ receipt,
298
+ idempotency_key=idempotency_key,
299
+ ) from exc
264
300
  receipt["searchable"] = True
265
301
  receipt["processing_status"] = "completed"
266
302
  receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
@@ -307,6 +343,8 @@ class SyncMemoriesResource:
307
343
  importance: Optional[float] = None,
308
344
  metadata: Optional[Dict[str, Any]] = None,
309
345
  wait_for_index: Optional[bool] = None,
346
+ index_timeout: float = 60.0,
347
+ index_poll_interval: float = 0.5,
310
348
  ) -> Dict[str, Any]:
311
349
  payload = {
312
350
  "content": content,
@@ -314,10 +352,18 @@ class SyncMemoriesResource:
314
352
  "metadata": metadata,
315
353
  "wait_for_index": wait_for_index,
316
354
  }
317
- return self.client.patch(
355
+ receipt = self.client.patch(
318
356
  f"/v1/memories/{memory_id}",
319
357
  json={key: value for key, value in payload.items() if value is not None},
320
358
  )
359
+ if wait_for_index is True:
360
+ receipt = {**receipt, "id": receipt.get("id") or memory_id}
361
+ receipt = self._ensure_searchable_receipt(
362
+ receipt,
363
+ timeout=index_timeout,
364
+ poll_interval=index_poll_interval,
365
+ )
366
+ return receipt
321
367
 
322
368
  def delete(self, memory_id: str) -> Dict[str, Any]:
323
369
  return self.client.delete(f"/v1/memories/{memory_id}")
@@ -743,7 +789,7 @@ class SyncMemoryClient:
743
789
  self.source = source or os.getenv("HEBBRIX_SOURCE")
744
790
  headers = {
745
791
  "Content-Type": "application/json",
746
- "User-Agent": "hebbrix-python/2.4.0",
792
+ "User-Agent": "hebbrix-python/2.4.1",
747
793
  }
748
794
  if api_key:
749
795
  headers["Authorization"] = f"Bearer {api_key}"
@@ -833,7 +879,21 @@ class SyncMemoryClient:
833
879
  response = self._client.request(method, path, **kwargs)
834
880
  if response.status_code >= 400:
835
881
  self._handle_error(response)
836
- return response.json() if response.text else {}
882
+ payload = response.json() if response.text else {}
883
+ if isinstance(payload, dict):
884
+ recovery_headers = {
885
+ "request_id": response.headers.get("X-Request-ID"),
886
+ "status_url": response.headers.get("Location"),
887
+ "outbox_event_id": response.headers.get("X-Hebbrix-Index-Event"),
888
+ "retry_after": response.headers.get("Retry-After"),
889
+ }
890
+ for key, value in recovery_headers.items():
891
+ if value and not payload.get(key):
892
+ payload[key] = value
893
+ replay = response.headers.get("X-Idempotent-Replay")
894
+ if replay is not None and "idempotency_replay" not in payload:
895
+ payload["idempotency_replay"] = replay.casefold() == "true"
896
+ return payload
837
897
 
838
898
  def get(self, path: str, **kwargs) -> Dict[str, Any]:
839
899
  return self.request("GET", path, **kwargs)