Skip to content

Commit d8a411b

Browse files
fix: search review follow-ups (index lock fallback, error body parse)
1 parent 781e1f7 commit d8a411b

6 files changed

Lines changed: 38 additions & 26 deletions

File tree

api/search.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@
3737
_MAX_SEARCH_SINCE_DAYS = 36_500
3838

3939

40-
class _SearchIndexUnavailable(Exception):
41-
"""Raised when the FTS index exists but is locked during rebuild."""
42-
43-
4440
def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int:
4541
"""Parse a positive integer limit from a query string value."""
4642
if raw is None or raw.strip() == "":
@@ -149,7 +145,13 @@ def _search_via_index(
149145
sql_offset=sql_offset,
150146
)
151147
if indexed["index_locked"]:
152-
raise _SearchIndexUnavailable()
148+
_logger.warning(
149+
"Search index locked during query; %d hit(s) collected so far",
150+
len(results),
151+
)
152+
if results:
153+
return _rank_search_hits(results)[:max_results]
154+
return None
153155
if not indexed["query_ok"]:
154156
return None
155157
if indexed["sql_rows_fetched"] == 0:
@@ -308,12 +310,6 @@ def search() -> FlaskReturn:
308310
max_results=max_results,
309311
)
310312
)
311-
except _SearchIndexUnavailable:
312-
return error_response(
313-
ErrorCode.SEARCH_INDEX_UNAVAILABLE,
314-
"Search index is temporarily unavailable",
315-
503,
316-
)
317313
except Exception:
318314
_logger.exception("Unexpected error during search")
319315
return error_response(

docs/api-reference.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ Extra fields may appear for specific codes (for example `since` on invalid bulk-
6060
| `SEARCH_QUERY_TOO_LONG` | 400 | `GET /api/search` | Query param `q` exceeds 500 characters |
6161
| `SEARCH_INVALID_SINCE_DAYS` | 400 | `GET /api/search` | Query param `since_days` is not a positive integer |
6262
| `SEARCH_PROJECTS_UNAVAILABLE` | 503 | `GET /api/search` | Claude projects directory exists but is not readable |
63-
| `SEARCH_INDEX_UNAVAILABLE` | 503 | `GET /api/search` | FTS index is locked during rebuild |
6463
| `INVALID_PATH` | 400 | Session, stats, export session | Path traversal or rejected URL segment |
6564
| `SESSION_NOT_FOUND` | 404 | Session, stats, export session | File missing on disk or session excluded by rules |
6665
| `INVALID_REQUEST_BODY` | 400 | `POST /api/export` | Body is not a JSON object |
@@ -320,7 +319,6 @@ By default, messages older than 30 days are excluded. Sessions without a parseab
320319
| 400 | `SEARCH_INVALID_LIMIT` | `limit` not a positive integer (e.g. `abc`, `0`, `1.5`) |
321320
| 400 | `SEARCH_INVALID_SINCE_DAYS` | `since_days` not a positive integer |
322321
| 503 | `SEARCH_PROJECTS_UNAVAILABLE` | Projects directory exists but is not readable |
323-
| 503 | `SEARCH_INDEX_UNAVAILABLE` | FTS index locked during rebuild |
324322
| 500 | `INTERNAL_ERROR` | Unexpected server failure |
325323

326324
```bash

models/error_codes.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ class ErrorCode(StrEnum):
1111
SEARCH_QUERY_TOO_LONG = "SEARCH_QUERY_TOO_LONG"
1212
SEARCH_INVALID_SINCE_DAYS = "SEARCH_INVALID_SINCE_DAYS"
1313
SEARCH_PROJECTS_UNAVAILABLE = "SEARCH_PROJECTS_UNAVAILABLE"
14-
SEARCH_INDEX_UNAVAILABLE = "SEARCH_INDEX_UNAVAILABLE"
1514
INVALID_PATH = "INVALID_PATH"
1615
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
1716
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"

static/js/search.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,14 @@ export async function doSearch() {
106106
if (!res.ok) {
107107
let message = `Search failed (${res.status})`;
108108
let code = '';
109+
const raw = await res.text();
109110
try {
110-
const body = await res.json();
111+
const body = JSON.parse(raw);
111112
if (body && typeof body.error === 'string') message = body.error;
112113
if (body && typeof body.code === 'string') code = body.code;
113114
} catch {
114-
try { message = await res.text() || message; } catch { /* ignore */ }
115+
const trimmed = raw.trim();
116+
if (trimmed) message = trimmed;
115117
}
116118
if (localRequestId !== lastSearchRequestId) return;
117119
const codeAttr = code ? ` data-error-code="${esc(code)}"` : '';

static/js/search.test.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,19 +126,36 @@ describe('search page', () => {
126126
fetch.mockResolvedValue({
127127
ok: false,
128128
status: 503,
129-
json: () => Promise.resolve({
130-
error: 'Search index is temporarily unavailable',
131-
code: 'SEARCH_INDEX_UNAVAILABLE',
132-
}),
129+
text: () => Promise.resolve(JSON.stringify({
130+
error: 'Claude projects directory is not accessible',
131+
code: 'SEARCH_PROJECTS_UNAVAILABLE',
132+
})),
133133
});
134134
document.getElementById('search-input').value = 'fail';
135135

136136
await doSearch();
137137

138138
const err = document.querySelector('.search-error');
139139
expect(err).not.toBeNull();
140-
expect(err.getAttribute('data-error-code')).toBe('SEARCH_INDEX_UNAVAILABLE');
141-
expect(err.textContent).toContain('temporarily unavailable');
140+
expect(err.getAttribute('data-error-code')).toBe('SEARCH_PROJECTS_UNAVAILABLE');
141+
expect(err.textContent).toContain('not accessible');
142+
});
143+
144+
it('doSearch shows plain-text error body when response is not JSON', async () => {
145+
showSearchPage();
146+
fetch.mockResolvedValue({
147+
ok: false,
148+
status: 502,
149+
text: () => Promise.resolve('Bad Gateway from proxy'),
150+
});
151+
document.getElementById('search-input').value = 'fail';
152+
153+
await doSearch();
154+
155+
const err = document.querySelector('.search-error');
156+
expect(err).not.toBeNull();
157+
expect(err.textContent).toBe('Bad Gateway from proxy');
158+
expect(err.getAttribute('data-error-code')).toBeNull();
142159
});
143160

144161
it('doSearch ignores stale responses when a newer request was started', async () => {

tests/test_search.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,22 +118,22 @@ def test_missing_projects_dir_is_not_unavailable(client_single, monkeypatch):
118118
assert resp.status_code == 200
119119

120120

121-
def test_index_unavailable_when_locked(tmp_path, monkeypatch):
121+
def test_index_lock_falls_back_to_live_scan(tmp_path, monkeypatch):
122122
recent_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
123123
client = _seed_indexed_client(tmp_path, monkeypatch, timestamp=recent_ts)
124124
with patch(
125125
"api.search.query_index_hits",
126126
return_value={
127127
"hits": [],
128-
"query_ok": False,
128+
"query_ok": True,
129129
"sql_rows_fetched": 0,
130130
"sql_exhausted": True,
131131
"index_locked": True,
132132
},
133133
):
134134
resp = client.get("/api/search?q=Hello")
135-
assert resp.status_code == 503
136-
assert_error_response(resp, expected_code="SEARCH_INDEX_UNAVAILABLE")
135+
assert resp.status_code == 200
136+
assert len(resp.get_json()) >= 1
137137

138138

139139
def _index_patches(cache_root: Path):

0 commit comments

Comments
 (0)