mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-07-15 21:11:05 +02:00
fix(cloud): align cloud/local backend contracts and harden the HTTP layer
Fixes the cloud/local contract mismatches from the PR #272 review (verified against the official API docs — the cloud API has no folder/collection endpoints publicly, GET /docs supports limit<=100 with offset): - query_stream: emit a terminal answer_done event with the full answer (same contract as the local backend); raise CloudAPIError instead of disguising HTTP errors as answer events; move the initial connect inside try so a connection failure can no longer strand the consumer awaiting a sentinel that never arrives - _request: rewind file objects before retrying so a transient 5xx/429 during upload no longer re-sends an empty multipart body; carry the HTTP status on CloudAPIError (status_code) and keep the last status in the max-retries error - list_documents: paginate with limit/offset instead of a hard-coded limit=100, so >100-doc collections are no longer silently truncated (whole-collection queries rely on this list) - folders: treat only 403/404 as "folders unavailable" (warned via warnings.warn instead of an invisible logger.warning, matched on status_code instead of a "403" substring); transient errors now propagate instead of being permanently cached as folder_id=None - error taxonomy parity: cloud doc endpoints map HTTP 404 to DocumentNotFoundError; local get_document raises DocumentNotFoundError instead of returning {}; local delete_document raises on missing doc_id instead of silently deleting nothing - cloud get_document warns that include_text is not supported instead of silently ignoring it Adds regression tests for each fix (11 new tests). Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
This commit is contained in:
parent
cc7e43c810
commit
284ce005f5
5 changed files with 366 additions and 74 deletions
|
|
@ -13,7 +13,7 @@ import urllib.parse
|
||||||
import requests
|
import requests
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
from ..errors import CloudAPIError, PageIndexError
|
from ..errors import CloudAPIError, DocumentNotFoundError, PageIndexError
|
||||||
from ..events import QueryEvent
|
from ..events import QueryEvent
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -32,33 +32,54 @@ class CloudBackend:
|
||||||
|
|
||||||
# ── HTTP helpers ──────────────────────────────────────────────────────
|
# ── HTTP helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Folder API statuses meaning "folders are not available on this account"
|
||||||
|
# (403: requires Max plan; 404: endpoint not exposed). Anything else is a
|
||||||
|
# real error and must propagate rather than silently degrade.
|
||||||
|
_FOLDER_UNAVAILABLE = (403, 404)
|
||||||
|
|
||||||
def _warn_folder_upgrade(self) -> None:
|
def _warn_folder_upgrade(self) -> None:
|
||||||
if not self._folder_warning_shown:
|
if not self._folder_warning_shown:
|
||||||
logger.warning(
|
import warnings
|
||||||
"Folders (collections) require a Max plan. "
|
warnings.warn(
|
||||||
|
"Folders (collections) are not available on this plan. "
|
||||||
"All documents are stored in a single global space — collection names are ignored. "
|
"All documents are stored in a single global space — collection names are ignored. "
|
||||||
"Upgrade at https://dash.pageindex.ai/subscription"
|
"Upgrade at https://dash.pageindex.ai/subscription",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=4,
|
||||||
)
|
)
|
||||||
self._folder_warning_shown = True
|
self._folder_warning_shown = True
|
||||||
|
|
||||||
def _request(self, method: str, path: str, **kwargs) -> dict:
|
def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||||
url = f"{API_BASE}{path}"
|
url = f"{API_BASE}{path}"
|
||||||
|
last_status: int | None = None
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
|
if attempt and "files" in kwargs:
|
||||||
|
# Rewind file objects before a retry — the previous attempt
|
||||||
|
# consumed them, and re-sending without seek(0) would upload
|
||||||
|
# an empty multipart body.
|
||||||
|
for value in kwargs["files"].values():
|
||||||
|
fobj = value[1] if isinstance(value, tuple) else value
|
||||||
|
if hasattr(fobj, "seek"):
|
||||||
|
fobj.seek(0)
|
||||||
try:
|
try:
|
||||||
resp = requests.request(method, url, headers=self._headers, timeout=30, **kwargs)
|
resp = requests.request(method, url, headers=self._headers, timeout=30, **kwargs)
|
||||||
if resp.status_code in (429, 500, 502, 503):
|
if resp.status_code in (429, 500, 502, 503):
|
||||||
logger.warning("Cloud API %s %s returned %d, retrying...", method, path, resp.status_code)
|
logger.warning("Cloud API %s %s returned %d, retrying...", method, path, resp.status_code)
|
||||||
|
last_status = resp.status_code
|
||||||
time.sleep(2 ** attempt)
|
time.sleep(2 ** attempt)
|
||||||
continue
|
continue
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
body = resp.text[:500] if resp.text else ""
|
body = resp.text[:500] if resp.text else ""
|
||||||
raise CloudAPIError(f"Cloud API error {resp.status_code}: {body}")
|
raise CloudAPIError(f"Cloud API error {resp.status_code}: {body}",
|
||||||
|
status_code=resp.status_code)
|
||||||
return resp.json() if resp.content else {}
|
return resp.json() if resp.content else {}
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
if attempt == 2:
|
if attempt == 2:
|
||||||
raise CloudAPIError(f"Cloud API request failed: {e}") from e
|
raise CloudAPIError(f"Cloud API request failed: {e}") from e
|
||||||
time.sleep(2 ** attempt)
|
time.sleep(2 ** attempt)
|
||||||
raise CloudAPIError("Max retries exceeded")
|
raise CloudAPIError(f"Cloud API {method} {path} failed after retries"
|
||||||
|
+ (f" (last status {last_status})" if last_status else ""),
|
||||||
|
status_code=last_status)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_collection_name(name: str) -> None:
|
def _validate_collection_name(name: str) -> None:
|
||||||
|
|
@ -80,7 +101,7 @@ class CloudBackend:
|
||||||
resp = self._request("POST", "/folder/", json={"name": name})
|
resp = self._request("POST", "/folder/", json={"name": name})
|
||||||
self._folder_id_cache[name] = resp.get("folder", {}).get("id")
|
self._folder_id_cache[name] = resp.get("folder", {}).get("id")
|
||||||
except CloudAPIError as e:
|
except CloudAPIError as e:
|
||||||
if "403" in str(e):
|
if e.status_code in self._FOLDER_UNAVAILABLE:
|
||||||
self._warn_folder_upgrade()
|
self._warn_folder_upgrade()
|
||||||
self._folder_id_cache[name] = None
|
self._folder_id_cache[name] = None
|
||||||
else:
|
else:
|
||||||
|
|
@ -97,24 +118,33 @@ class CloudBackend:
|
||||||
resp = self._request("POST", "/folder/", json={"name": name})
|
resp = self._request("POST", "/folder/", json={"name": name})
|
||||||
self._folder_id_cache[name] = resp.get("folder", {}).get("id")
|
self._folder_id_cache[name] = resp.get("folder", {}).get("id")
|
||||||
except CloudAPIError as e:
|
except CloudAPIError as e:
|
||||||
if "403" in str(e):
|
if e.status_code in self._FOLDER_UNAVAILABLE:
|
||||||
self._warn_folder_upgrade()
|
self._warn_folder_upgrade()
|
||||||
self._folder_id_cache[name] = None
|
self._folder_id_cache[name] = None
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _get_folder_id(self, name: str) -> str | None:
|
def _get_folder_id(self, name: str) -> str | None:
|
||||||
"""Resolve collection name to folder ID. Returns None if folders not available."""
|
"""Resolve collection name to folder ID. Returns None if folders not available.
|
||||||
|
|
||||||
|
Only "folders unavailable on this plan" (403/404) is cached as None —
|
||||||
|
transient errors (network, 5xx) propagate so a blip can't silently
|
||||||
|
drop documents into the global space forever.
|
||||||
|
"""
|
||||||
if name in self._folder_id_cache:
|
if name in self._folder_id_cache:
|
||||||
return self._folder_id_cache.get(name)
|
return self._folder_id_cache.get(name)
|
||||||
try:
|
try:
|
||||||
data = self._request("GET", "/folders/")
|
data = self._request("GET", "/folders/")
|
||||||
for folder in data.get("folders", []):
|
except CloudAPIError as e:
|
||||||
if folder.get("name") == name:
|
if e.status_code in self._FOLDER_UNAVAILABLE:
|
||||||
self._folder_id_cache[name] = folder["id"]
|
self._warn_folder_upgrade()
|
||||||
return folder["id"]
|
self._folder_id_cache[name] = None
|
||||||
except CloudAPIError:
|
return None
|
||||||
pass
|
raise
|
||||||
|
for folder in data.get("folders", []):
|
||||||
|
if folder.get("name") == name:
|
||||||
|
self._folder_id_cache[name] = folder["id"]
|
||||||
|
return folder["id"]
|
||||||
self._folder_id_cache[name] = None
|
self._folder_id_cache[name] = None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -153,11 +183,30 @@ class CloudBackend:
|
||||||
|
|
||||||
raise CloudAPIError(f"Document {doc_id} indexing timed out")
|
raise CloudAPIError(f"Document {doc_id} indexing timed out")
|
||||||
|
|
||||||
|
def _doc_request(self, doc_id: str, method: str, path: str, **kwargs) -> dict:
|
||||||
|
"""Doc-scoped request: maps HTTP 404 to DocumentNotFoundError for
|
||||||
|
parity with the local backend's error taxonomy."""
|
||||||
|
try:
|
||||||
|
return self._request(method, path, **kwargs)
|
||||||
|
except CloudAPIError as e:
|
||||||
|
if e.status_code == 404:
|
||||||
|
raise DocumentNotFoundError(f"Document {doc_id} not found") from e
|
||||||
|
raise
|
||||||
|
|
||||||
def get_document(self, collection: str, doc_id: str, include_text: bool = False) -> dict:
|
def get_document(self, collection: str, doc_id: str, include_text: bool = False) -> dict:
|
||||||
resp = self._request("GET", f"/doc/{self._enc(doc_id)}/metadata/")
|
if include_text:
|
||||||
|
import warnings
|
||||||
|
warnings.warn(
|
||||||
|
"include_text is not supported by the cloud backend; "
|
||||||
|
"returning the structure without node text. "
|
||||||
|
"Use get_page_content(doc_id, pages) to fetch content.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
resp = self._doc_request(doc_id, "GET", f"/doc/{self._enc(doc_id)}/metadata/")
|
||||||
# Fetch structure in the same call via tree endpoint
|
# Fetch structure in the same call via tree endpoint
|
||||||
tree_resp = self._request("GET", f"/doc/{self._enc(doc_id)}/",
|
tree_resp = self._doc_request(doc_id, "GET", f"/doc/{self._enc(doc_id)}/",
|
||||||
params={"type": "tree", "summary": "true"})
|
params={"type": "tree", "summary": "true"})
|
||||||
raw_tree = tree_resp.get("tree", tree_resp.get("structure", tree_resp.get("result", [])))
|
raw_tree = tree_resp.get("tree", tree_resp.get("structure", tree_resp.get("result", [])))
|
||||||
return {
|
return {
|
||||||
"doc_id": resp.get("id", doc_id),
|
"doc_id": resp.get("id", doc_id),
|
||||||
|
|
@ -169,12 +218,14 @@ class CloudBackend:
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_document_structure(self, collection: str, doc_id: str) -> list:
|
def get_document_structure(self, collection: str, doc_id: str) -> list:
|
||||||
resp = self._request("GET", f"/doc/{self._enc(doc_id)}/", params={"type": "tree", "summary": "true"})
|
resp = self._doc_request(doc_id, "GET", f"/doc/{self._enc(doc_id)}/",
|
||||||
|
params={"type": "tree", "summary": "true"})
|
||||||
raw_tree = resp.get("tree", resp.get("structure", resp.get("result", [])))
|
raw_tree = resp.get("tree", resp.get("structure", resp.get("result", [])))
|
||||||
return self._normalize_tree(raw_tree)
|
return self._normalize_tree(raw_tree)
|
||||||
|
|
||||||
def get_page_content(self, collection: str, doc_id: str, pages: str) -> list:
|
def get_page_content(self, collection: str, doc_id: str, pages: str) -> list:
|
||||||
resp = self._request("GET", f"/doc/{self._enc(doc_id)}/", params={"type": "ocr", "format": "page"})
|
resp = self._doc_request(doc_id, "GET", f"/doc/{self._enc(doc_id)}/",
|
||||||
|
params={"type": "ocr", "format": "page"})
|
||||||
# Filter to requested pages
|
# Filter to requested pages
|
||||||
from ..index.utils import parse_pages
|
from ..index.utils import parse_pages
|
||||||
page_nums = set(parse_pages(pages))
|
page_nums = set(parse_pages(pages))
|
||||||
|
|
@ -210,22 +261,33 @@ class CloudBackend:
|
||||||
|
|
||||||
def list_documents(self, collection: str) -> list[dict]:
|
def list_documents(self, collection: str) -> list[dict]:
|
||||||
folder_id = self._get_folder_id(collection)
|
folder_id = self._get_folder_id(collection)
|
||||||
params = {"limit": 100}
|
# The API caps `limit` at 100; paginate with `offset` until a short
|
||||||
if folder_id:
|
# page comes back so collections with >100 docs aren't silently
|
||||||
params["folder_id"] = folder_id
|
# truncated (queries over the whole collection rely on this list).
|
||||||
data = self._request("GET", "/docs/", params=params)
|
page_size = 100
|
||||||
return [
|
offset = 0
|
||||||
{
|
docs: list[dict] = []
|
||||||
"doc_id": d.get("id", ""),
|
while True:
|
||||||
"doc_name": d.get("name", ""),
|
params = {"limit": page_size, "offset": offset}
|
||||||
"doc_description": d.get("description", ""),
|
if folder_id:
|
||||||
"doc_type": "pdf",
|
params["folder_id"] = folder_id
|
||||||
}
|
data = self._request("GET", "/docs/", params=params)
|
||||||
for d in data.get("documents", [])
|
batch = data.get("documents", [])
|
||||||
]
|
docs.extend(
|
||||||
|
{
|
||||||
|
"doc_id": d.get("id", ""),
|
||||||
|
"doc_name": d.get("name", ""),
|
||||||
|
"doc_description": d.get("description", ""),
|
||||||
|
"doc_type": "pdf",
|
||||||
|
}
|
||||||
|
for d in batch
|
||||||
|
)
|
||||||
|
if len(batch) < page_size:
|
||||||
|
return docs
|
||||||
|
offset += page_size
|
||||||
|
|
||||||
def delete_document(self, collection: str, doc_id: str) -> None:
|
def delete_document(self, collection: str, doc_id: str) -> None:
|
||||||
self._request("DELETE", f"/doc/{self._enc(doc_id)}/")
|
self._doc_request(doc_id, "DELETE", f"/doc/{self._enc(doc_id)}/")
|
||||||
|
|
||||||
# ── Query (uses cloud chat/completions, no LLM key needed) ────────────
|
# ── Query (uses cloud chat/completions, no LLM key needed) ────────────
|
||||||
|
|
||||||
|
|
@ -269,32 +331,45 @@ class CloudBackend:
|
||||||
)
|
)
|
||||||
doc_id = doc_ids if doc_ids else self._get_all_doc_ids(collection)
|
doc_id = doc_ids if doc_ids else self._get_all_doc_ids(collection)
|
||||||
headers = self._headers
|
headers = self._headers
|
||||||
queue: asyncio.Queue[QueryEvent | None] = asyncio.Queue()
|
# Queue carries QueryEvent, an Exception to re-raise, or None (end).
|
||||||
loop = asyncio.get_event_loop()
|
queue: asyncio.Queue[QueryEvent | Exception | None] = asyncio.Queue()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def _put(item: QueryEvent | Exception | None) -> None:
|
||||||
|
try:
|
||||||
|
loop.call_soon_threadsafe(queue.put_nowait, item)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # event loop already closed; consumer is gone
|
||||||
|
|
||||||
def _stream():
|
def _stream():
|
||||||
"""Background thread: read SSE and push events to queue."""
|
"""Background thread: read SSE and push events to queue.
|
||||||
resp = requests.post(
|
|
||||||
f"{API_BASE}/chat/completions/",
|
Everything — including the initial connect — runs inside try so a
|
||||||
headers=headers,
|
failure can never die silently and leave the consumer awaiting a
|
||||||
json={
|
sentinel that never arrives. Errors are forwarded as exceptions
|
||||||
"messages": [{"role": "user", "content": question}],
|
(raised in the consumer), never disguised as answer events.
|
||||||
"doc_id": doc_id,
|
"""
|
||||||
"stream": True,
|
resp = None
|
||||||
"stream_metadata": True,
|
answer_parts: list[str] = []
|
||||||
},
|
|
||||||
stream=True,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{API_BASE}/chat/completions/",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"messages": [{"role": "user", "content": question}],
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"stream": True,
|
||||||
|
"stream_metadata": True,
|
||||||
|
},
|
||||||
|
stream=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
body = resp.text[:500] if resp.text else ""
|
body = resp.text[:500] if resp.text else ""
|
||||||
loop.call_soon_threadsafe(
|
raise CloudAPIError(
|
||||||
queue.put_nowait,
|
f"Cloud streaming error {resp.status_code}: {body}",
|
||||||
QueryEvent(type="answer_done",
|
status_code=resp.status_code,
|
||||||
data=f"Cloud streaming error {resp.status_code}: {body}"),
|
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
current_tool_name = None
|
current_tool_name = None
|
||||||
current_tool_args: list[str] = []
|
current_tool_args: list[str] = []
|
||||||
|
|
@ -327,34 +402,40 @@ class CloudBackend:
|
||||||
elif block_type == "tool_use_stop":
|
elif block_type == "tool_use_stop":
|
||||||
if current_tool_name and current_tool_name not in _INTERNAL_TOOLS:
|
if current_tool_name and current_tool_name not in _INTERNAL_TOOLS:
|
||||||
args_str = "".join(current_tool_args)
|
args_str = "".join(current_tool_args)
|
||||||
loop.call_soon_threadsafe(
|
_put(QueryEvent(type="tool_call", data={
|
||||||
queue.put_nowait,
|
"name": current_tool_name,
|
||||||
QueryEvent(type="tool_call", data={
|
"args": args_str,
|
||||||
"name": current_tool_name,
|
}))
|
||||||
"args": args_str,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
current_tool_name = None
|
current_tool_name = None
|
||||||
current_tool_args = []
|
current_tool_args = []
|
||||||
|
|
||||||
elif block_type == "text" and content:
|
elif block_type == "text" and content:
|
||||||
loop.call_soon_threadsafe(
|
answer_parts.append(content)
|
||||||
queue.put_nowait,
|
_put(QueryEvent(type="answer_delta", data=content))
|
||||||
QueryEvent(type="answer_delta", data=content),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Same terminal contract as the local backend: a final
|
||||||
|
# answer_done event carrying the full answer text.
|
||||||
|
_put(QueryEvent(type="answer_done", data="".join(answer_parts)))
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
_put(CloudAPIError(f"Cloud streaming request failed: {e}"))
|
||||||
|
except Exception as e:
|
||||||
|
_put(e)
|
||||||
finally:
|
finally:
|
||||||
resp.close()
|
if resp is not None:
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, None) # sentinel
|
resp.close()
|
||||||
|
_put(None) # sentinel
|
||||||
|
|
||||||
thread = threading.Thread(target=_stream, daemon=True)
|
thread = threading.Thread(target=_stream, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
event = await queue.get()
|
item = await queue.get()
|
||||||
if event is None:
|
if item is None:
|
||||||
break
|
break
|
||||||
yield event
|
if isinstance(item, Exception):
|
||||||
|
raise item
|
||||||
|
yield item
|
||||||
|
|
||||||
thread.join(timeout=5)
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ class LocalBackend:
|
||||||
"""
|
"""
|
||||||
doc = self._storage.get_document(collection, doc_id)
|
doc = self._storage.get_document(collection, doc_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
return {}
|
raise DocumentNotFoundError(f"Document {doc_id} not found")
|
||||||
doc["structure"] = self._storage.get_document_structure(collection, doc_id)
|
doc["structure"] = self._storage.get_document_structure(collection, doc_id)
|
||||||
if include_text:
|
if include_text:
|
||||||
pages = self._storage.get_pages(collection, doc_id) or []
|
pages = self._storage.get_pages(collection, doc_id) or []
|
||||||
|
|
@ -190,7 +190,11 @@ class LocalBackend:
|
||||||
|
|
||||||
def delete_document(self, collection: str, doc_id: str) -> None:
|
def delete_document(self, collection: str, doc_id: str) -> None:
|
||||||
doc = self._storage.get_document(collection, doc_id)
|
doc = self._storage.get_document(collection, doc_id)
|
||||||
if doc and doc.get("file_path"):
|
if not doc:
|
||||||
|
# Parity with the cloud backend, which surfaces HTTP 404 as
|
||||||
|
# DocumentNotFoundError — a typo'd doc_id should not pass silently.
|
||||||
|
raise DocumentNotFoundError(f"Document {doc_id} not found")
|
||||||
|
if doc.get("file_path"):
|
||||||
Path(doc["file_path"]).unlink(missing_ok=True)
|
Path(doc["file_path"]).unlink(missing_ok=True)
|
||||||
# Clean up images directory: files/{collection}/{doc_id}/
|
# Clean up images directory: files/{collection}/{doc_id}/
|
||||||
doc_dir = self._files_dir / collection / doc_id
|
doc_dir = self._files_dir / collection / doc_id
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,16 @@ class PageIndexAPIError(PageIndexError):
|
||||||
|
|
||||||
|
|
||||||
class CloudAPIError(PageIndexAPIError):
|
class CloudAPIError(PageIndexAPIError):
|
||||||
"""Cloud API returned error."""
|
"""Cloud API returned error.
|
||||||
pass
|
|
||||||
|
``status_code`` carries the HTTP status when the error came from an HTTP
|
||||||
|
response (None for transport-level failures), so callers can branch on it
|
||||||
|
instead of parsing the message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
class FileTypeError(PageIndexError):
|
class FileTypeError(PageIndexError):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pageindex.backend.cloud as cloud_mod
|
||||||
from pageindex.backend.cloud import CloudBackend, API_BASE
|
from pageindex.backend.cloud import CloudBackend, API_BASE
|
||||||
|
from pageindex.errors import CloudAPIError, DocumentNotFoundError
|
||||||
|
|
||||||
|
|
||||||
def test_cloud_backend_init():
|
def test_cloud_backend_init():
|
||||||
|
|
@ -17,3 +23,182 @@ def test_query_rejects_empty_doc_ids():
|
||||||
backend = CloudBackend(api_key="pi-test")
|
backend = CloudBackend(api_key="pi-test")
|
||||||
with pytest.raises(ValueError, match="cannot be empty"):
|
with pytest.raises(ValueError, match="cannot be empty"):
|
||||||
backend.query("col", "q", doc_ids=[])
|
backend.query("col", "q", doc_ids=[])
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, status_code=200, json_data=None, text="", lines=None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._json = json_data if json_data is not None else {}
|
||||||
|
self.text = text
|
||||||
|
self.content = json.dumps(self._json).encode() if json_data is not None else b""
|
||||||
|
self._lines = lines or []
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._json
|
||||||
|
|
||||||
|
def iter_lines(self, decode_unicode=True):
|
||||||
|
yield from self._lines
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_sleep(monkeypatch):
|
||||||
|
monkeypatch.setattr(cloud_mod.time, "sleep", lambda *_: None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _request: retry must rewind file objects (empty-upload regression) ──────
|
||||||
|
|
||||||
|
def test_request_rewinds_file_on_retry(monkeypatch):
|
||||||
|
backend = CloudBackend(api_key="pi-test")
|
||||||
|
payload = b"%PDF-1.4 fake body"
|
||||||
|
fobj = io.BytesIO(payload)
|
||||||
|
uploads = []
|
||||||
|
|
||||||
|
def fake_request(method, url, headers=None, timeout=None, **kwargs):
|
||||||
|
# Simulate requests consuming the file body on every attempt.
|
||||||
|
uploads.append(kwargs["files"]["file"].read())
|
||||||
|
if len(uploads) == 1:
|
||||||
|
return FakeResponse(status_code=500)
|
||||||
|
return FakeResponse(status_code=200, json_data={"doc_id": "d1"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(cloud_mod.requests, "request", fake_request)
|
||||||
|
resp = backend._request("POST", "/doc/", files={"file": fobj}, data={})
|
||||||
|
assert resp == {"doc_id": "d1"}
|
||||||
|
assert uploads[0] == payload
|
||||||
|
# Without seek(0) the retry would upload an empty body.
|
||||||
|
assert uploads[1] == payload
|
||||||
|
|
||||||
|
|
||||||
|
# ── list_documents: pagination beyond the API's 100-doc page cap ─────────────
|
||||||
|
|
||||||
|
def test_list_documents_paginates(monkeypatch):
|
||||||
|
backend = CloudBackend(api_key="pi-test")
|
||||||
|
backend._folder_id_cache["col"] = None # skip folder lookup
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, path, **kwargs):
|
||||||
|
offset = kwargs["params"]["offset"]
|
||||||
|
calls.append(offset)
|
||||||
|
n = 100 if offset == 0 else 30
|
||||||
|
return {"documents": [{"id": f"d{offset + i}", "name": ""} for i in range(n)]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(backend, "_request", fake_request)
|
||||||
|
docs = backend.list_documents("col")
|
||||||
|
assert len(docs) == 130
|
||||||
|
assert calls == [0, 100]
|
||||||
|
assert docs[-1]["doc_id"] == "d129"
|
||||||
|
|
||||||
|
|
||||||
|
# ── folder resolution: plan-limit vs transient errors ────────────────────────
|
||||||
|
|
||||||
|
def test_folder_unavailable_warns_and_caches(monkeypatch):
|
||||||
|
backend = CloudBackend(api_key="pi-test")
|
||||||
|
|
||||||
|
def fake_request(method, path, **kwargs):
|
||||||
|
raise CloudAPIError("Cloud API error 403: upgrade", status_code=403)
|
||||||
|
|
||||||
|
monkeypatch.setattr(backend, "_request", fake_request)
|
||||||
|
with pytest.warns(UserWarning, match="not available on this plan"):
|
||||||
|
assert backend._get_folder_id("col") is None
|
||||||
|
assert backend._folder_id_cache["col"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_transient_error_propagates_and_is_not_cached(monkeypatch):
|
||||||
|
backend = CloudBackend(api_key="pi-test")
|
||||||
|
|
||||||
|
def fake_request(method, path, **kwargs):
|
||||||
|
raise CloudAPIError("Cloud API request failed: connection reset")
|
||||||
|
|
||||||
|
monkeypatch.setattr(backend, "_request", fake_request)
|
||||||
|
with pytest.raises(CloudAPIError):
|
||||||
|
backend._get_folder_id("col")
|
||||||
|
# A blip must not permanently route documents to the global space.
|
||||||
|
assert "col" not in backend._folder_id_cache
|
||||||
|
|
||||||
|
|
||||||
|
# ── doc endpoints: 404 maps to DocumentNotFoundError (local parity) ──────────
|
||||||
|
|
||||||
|
def test_doc_404_maps_to_document_not_found(monkeypatch):
|
||||||
|
backend = CloudBackend(api_key="pi-test")
|
||||||
|
|
||||||
|
def fake_request(method, path, **kwargs):
|
||||||
|
raise CloudAPIError("Cloud API error 404: not found", status_code=404)
|
||||||
|
|
||||||
|
monkeypatch.setattr(backend, "_request", fake_request)
|
||||||
|
with pytest.raises(DocumentNotFoundError):
|
||||||
|
backend.get_document_structure("col", "missing")
|
||||||
|
with pytest.raises(DocumentNotFoundError):
|
||||||
|
backend.delete_document("col", "missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_document_include_text_warns(monkeypatch):
|
||||||
|
backend = CloudBackend(api_key="pi-test")
|
||||||
|
monkeypatch.setattr(backend, "_doc_request", lambda *a, **k: {"tree": []})
|
||||||
|
with pytest.warns(UserWarning, match="include_text is not supported"):
|
||||||
|
backend.get_document("col", "d1", include_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── query_stream: terminal contract and error propagation ───────────────────
|
||||||
|
|
||||||
|
def _collect_events(backend, **kwargs):
|
||||||
|
async def _run():
|
||||||
|
events = []
|
||||||
|
async for ev in backend.query_stream("col", "q", doc_ids=["d1"], **kwargs):
|
||||||
|
events.append(ev)
|
||||||
|
return events
|
||||||
|
return asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(block_type, content):
|
||||||
|
return "data: " + json.dumps({
|
||||||
|
"block_metadata": {"type": block_type},
|
||||||
|
"choices": [{"delta": {"content": content}}],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_stream_emits_answer_done(monkeypatch):
|
||||||
|
backend = CloudBackend(api_key="pi-test")
|
||||||
|
lines = [_sse("text", "Hello "), _sse("text", "world"), "data: [DONE]"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cloud_mod.requests, "post",
|
||||||
|
lambda *a, **k: FakeResponse(status_code=200, lines=lines),
|
||||||
|
)
|
||||||
|
events = _collect_events(backend)
|
||||||
|
assert [e.type for e in events] == ["answer_delta", "answer_delta", "answer_done"]
|
||||||
|
# Same contract as the local backend: answer_done carries the full text.
|
||||||
|
assert events[-1].data == "Hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_stream_http_error_raises(monkeypatch):
|
||||||
|
backend = CloudBackend(api_key="pi-test")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cloud_mod.requests, "post",
|
||||||
|
lambda *a, **k: FakeResponse(status_code=401, text="unauthorized"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
async for _ in backend.query_stream("col", "q", doc_ids=["d1"]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(CloudAPIError, match="401"):
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_stream_connect_failure_raises_instead_of_hanging(monkeypatch):
|
||||||
|
backend = CloudBackend(api_key="pi-test")
|
||||||
|
|
||||||
|
def fake_post(*a, **k):
|
||||||
|
raise cloud_mod.requests.ConnectionError("dns failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(cloud_mod.requests, "post", fake_post)
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
async for _ in backend.query_stream("col", "q", doc_ids=["d1"]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(CloudAPIError, match="request failed"):
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
|
||||||
|
|
@ -141,3 +141,17 @@ def test_normalize_doc_ids():
|
||||||
def test_normalize_doc_ids_rejects_empty_list():
|
def test_normalize_doc_ids_rejects_empty_list():
|
||||||
with pytest.raises(ValueError, match="cannot be empty"):
|
with pytest.raises(ValueError, match="cannot be empty"):
|
||||||
LocalBackend._normalize_doc_ids([])
|
LocalBackend._normalize_doc_ids([])
|
||||||
|
|
||||||
|
|
||||||
|
# ── error taxonomy: missing docs raise DocumentNotFoundError ─────────────────
|
||||||
|
|
||||||
|
def test_get_document_missing_raises(backend):
|
||||||
|
backend.get_or_create_collection("papers")
|
||||||
|
with pytest.raises(DocumentNotFoundError, match="ghost"):
|
||||||
|
backend.get_document("papers", "ghost")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_document_missing_raises(backend):
|
||||||
|
backend.get_or_create_collection("papers")
|
||||||
|
with pytest.raises(DocumentNotFoundError, match="ghost"):
|
||||||
|
backend.delete_document("papers", "ghost")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue