ReMe/reme/components
北沐城歌 6fb402dcd1
fix(embedding): keep one bad item from dropping its whole batch (#579)
* fix(embedding): keep one bad item from dropping its whole batch

A provider-side request error rejects an entire batch, so a single over-limit
item used to cost the vectors of every item beside it. QwenPaw #8040 shows two
poison chunks turning into exactly twenty lost vectors, while the backfill loop
still reported full success.

Retry a rejected multi-item request one item at a time so only the item that
actually fails is left without a vector. Single-item requests keep their
previous behaviour, and provider-level failures (429 / quota) keep using the
existing retry paths instead of being split.

Also make the backfill loop report what it actually filled, and name the failing
chunk ids in the final reindex error. `processed` counts attempts, so it claimed
success after a partial loss.

* fix(embedding): stop quota failures and transient item errors from multiplying

Review follow-up on the two P2 findings in #579:

* quota exhaustion no longer falls into the per-item split. The store now gives
  up explicitly when the provider reports insufficient_quota, including after
  the opt-in quota retries are spent, because no single item is to blame and
  splitting only multiplied the rejected requests (3 items -> 4 requests).
* the per-item fallback goes back through `_call_with_retry`, so a 429,
  connection error or timeout on an isolated item keeps the existing backoff
  and retry policy instead of silently dropping that item's vector. A one-item
  request still cannot split again.

Regression tests cover both findings: a quota failure stays a single request
with the default policy and with exhausted quota retries, and a transient
failure on one item of a split batch still ends with every healthy sibling
embedded.

python -m pytest tests/unit/test_local_embedding_store.py tests/unit/test_file_store_consistency.py -q
-> 83 passed, 41 skipped

* style(test): hoist the sleeps collector out of the transient-failure loop

CI / Python quality failed on d5dc9b2: the pylint hook reported

    tests/unit/test_local_embedding_store.py:935:16:
    W0640: Cell variable sleeps defined in loop (cell-var-from-loop)

`fake_sleep` closes over `sleeps` while the loop rebinds it on every
iteration. The collector is now created once before the loop and cleared
at the top of each iteration, which keeps the per-iteration assertion
(`assert sleeps == [1.0]`) exactly as strict and removes the loop-scoped
rebinding.

Verification (repo hook arguments):
  pylint  -> Your code has been rated at 10.00/10, exit 0
  black --check --line-length=120 --target-version=py311 -> clean
  flake8 --extend-ignore=E203 --max-line-length=120       -> clean

* fix(embedding): keep provider-wide failures out of the per-item split

Review follow-up on the P2 raised against 4d8a3c5.

`_call_with_retry` sent every unrecognized exception to `_embed_individually`
whenever the request had more than one item. Only rate limiting and
`insufficient_quota` were excluded, so a 401, a 403 or a 5xx still split a
ten-item request into ten extra single-item requests, none of which can
succeed: credentials, permissions and server outages are properties of the
endpoint, not of any individual input.

The split path is now gated on a dedicated classifier,
`_is_provider_wide_failure()`, which recognizes 401/403 and 5xx by status
code (with the OpenAI SDK class names as a fallback for exceptions that do
not expose one). Transient server errors keep their retry policy through the
existing backoff; the remaining provider-wide failures stop the loop and
report unhealthy, like the quota branch. Input-dependent rejections such as
the per-item context-length limit are unchanged and still isolate one item
at a time.

Regression coverage: test_provider_wide_credentials_errors_are_not_split_into_per_item_requests
(401/403 -> a single request of size 10) and
test_provider_wide_server_errors_are_retried_without_splitting
(500 -> batch sizes [10, 10, 10] with sleeps [1.0, 2.0]). The existing
context-length, quota, 429 and per-item transient-retry tests are retained.

Local verification, using the repo's own hook arguments:
  pylint  -> Your code has been rated at 10.00/10, exit 0
  black   -> 2 files would be left unchanged
  flake8  -> clean
Classifier discriminator check (old vs new split decision, extracted from the
production file and executed):
  400 context-length     -> SPLIT / SPLIT
  401, 403, 500, 504     -> SPLIT / NO-SPLIT
  429 and insufficient_quota keep their own earlier branches.

* fix(embedding): retry SDK transport failures instead of splitting the batch

Third review follow-up on #579.

`_call_with_retry` only caught the built-in `TimeoutError`, `ConnectionError`
and `OSError`. `openai.APIConnectionError` and `openai.APITimeoutError` do not
inherit from any of them (they go APIError -> OpenAIError -> Exception), so
they reached the generic handler: `_is_provider_wide_failure()` did not
recognize them either, and a multi-item request fanned out into per-item
requests that failed for exactly the same reason. The same gap meant a
single-item request got no ReMe-level retry at all after a transport failure.

Transport failures are now recognized by a dedicated classifier,
`_is_transient_transport_failure()`, covering the built-in socket errors as
well as the SDK/httpx exception types (matched across the MRO, so subclasses
such as `APITimeoutError` are included) without importing the provider SDK.
They retry the original request with the existing backoff and never split; the
retry budget is unchanged.

Regression coverage:
- test_sdk_transport_errors_are_retried_without_splitting
  APIConnectionError / APITimeoutError -> batch sizes [10, 10, 10], sleeps
  [1.0, 2.0], then failure with no single-item requests.
- test_sdk_transport_error_recovers_without_losing_the_batch
  one APITimeoutError then success -> [[1.0, 0.0]] * 10, batch sizes [10, 10].
- test_isolated_item_retries_an_sdk_transport_error
  context-length rejection, then APIConnectionError on one isolated item ->
  that item's vector is retained.
The existing context-length, 401/403, 5xx, quota and rate-limit tests are
retained.

Local verification with the repo's own hook arguments:
  pylint -> Your code has been rated at 10.00/10, exit 0
  black  -> 2 files would be left unchanged
  flake8 -> clean

Branch-order discriminator check (classifiers extracted from the production
file and executed against each error type):

  case                      old     new
  400 context-length        SPLIT   SPLIT
  401 / 403                 SPLIT   STOP
  500                       SPLIT   RETRY
  SDK APIConnectionError    SPLIT   RETRY
  SDK APITimeoutError       SPLIT   RETRY
  builtin ConnectionError   RETRY   RETRY
  builtin TimeoutError      RETRY   RETRY
  429                       RETRY   RETRY
  insufficient_quota        STOP    STOP

* fix(embedding): treat 404 as a provider-wide failure too

Fourth review follow-up on #579.

The classifier added in c9448ae covered 401/403 and 5xx, but not 404. A missing
model or a wrong embedding route answers with 404 (SDK `NotFoundError`), which
no individual input can explain, so the batch still fell through to
`_embed_individually()`: a ten-item request against a bad model name produced
eleven requests, all failing for the same reason.

404 is now part of the provider-wide set (`_PROVIDER_WIDE_STATUS_CODES`) and
`NotFoundError` joins the class-name fallback. Like 401/403 it is not
transient, so the request is reported as failed once instead of being retried
or split. The context-length `400` is deliberately left out of that set: an
over-limit item is the one status a single input can explain by itself, so it
has to stay isolatable.

Regression coverage:
- test_provider_wide_statuses_exclude_input_dependent_bad_request
  401/403/404/500 -> provider-wide; an over-limit 400 -> not provider-wide.
- test_provider_wide_not_found_errors_are_not_split
  404 -> batch sizes [10], no sleeps, no single-item requests.
The existing context-length isolation, 401/403, 5xx, quota, rate-limit and SDK
transport tests are retained.

Local verification with the repo's own hook arguments:
  pylint -> Your code has been rated at 10.00/10, exit 0
  black  -> 2 files would be left unchanged
  flake8 -> clean

Branch-order discriminator check (classifiers extracted from the production
file and executed against each error type):

  case                      old     new
  400 context-length        SPLIT   SPLIT
  401 / 403                 SPLIT   STOP
  404 NotFoundError         SPLIT   STOP
  500                       SPLIT   RETRY
  SDK APIConnectionError    SPLIT   RETRY
  SDK APITimeoutError       SPLIT   RETRY
  builtin ConnectionError   RETRY   RETRY
  builtin TimeoutError      RETRY   RETRY
  429                       RETRY   RETRY
  insufficient_quota        STOP    STOP

* fix(embedding): classify every provider status shape and split only input errors

The per-item fallback still multiplied service failures into one request per item.
Both gaps come from the fallback this PR adds, so they are fixed here.

Status extraction
- `_http_status()` reads the status from `status_code`, an integer `code` (Google GenAI
  exposes the HTTP status there), `response.status_code` (httpx-style wrappers), and the
  structured `status_code` embedded in the message (AgentScope's DashScope embedding turns
  every non-200 response into a plain `RuntimeError`).
- Only integers are read from `code`. A string `code` names an error
  (`insufficient_quota`, `rate_limit_exceeded`, `InvalidApiKey`); reading it as a status
  would misclassify quota and rate-limit errors.
- `_is_provider_wide_failure`, `_is_transient_provider_failure`, `_is_rate_limited` and
  `_is_input_dependent_failure` all classify through it.

Splitting
- The generic fallback splits only when `_is_input_dependent_failure()` matches: a
  context-length rejection, the one error a single over-long item explains by itself.
  Anything unrecognized is provider-wide and is never answered with one request per item.
- This narrows the isolation this PR adds. An input-dependent error that carries neither a
  status nor an input-length signal is no longer split, so it costs its batch instead of
  multiplying the requests. Erring towards the batch keeps one provider failure from
  becoming N failures and losing the healthy vectors anyway.
- `_embed_individually()` drops the redundant `len(single) == 1` check: the
  `_call_with_retry([text])` contract only returns a result of the expected length.

Verified against the real exception classes: `google.genai.errors.APIError.code` is an
`int`, and a real DashScope response yields a `RuntimeError` whose message carries
`"status_code"`. Regression coverage uses the real classes when available and mirrors
their shape otherwise.

* fix(embedding): stop per-item isolation on a provider-wide failure and read serialized codes

- `_embed_individually` now stops asking for the remaining items as soon as one isolated
  item fails provider-wide (credentials, missing model, exhausted quota, exhausted
  transient retries). No remaining input can explain that failure, so continuing only
  multiplies the rejected requests. The vectors already recovered are kept and the items
  that were never answered stay `None`.

- `_call_with_retry` reports that outcome through an optional `terminal_failure`
  out-parameter, so its return type and every existing call site stay unchanged.

- `_error_code` reads the business error code from the attribute, the structured body, or
  the serialized message. AgentScope's DashScope embedding raises a plain `RuntimeError`
  with the response embedded in the message, so a quota error was classified as a rate
  limit and never used the configured `quota_retry_delay`.

Regression tests cover both halves of the isolation boundary (a provider-wide failure
stops the loop; an input-dependent failure does not) and the serialized DashScope quota
path with quota retries enabled.

* fix(embedding): treat an input-length 413 as a per-item rejection

* fix(embedding): keep the health state after a partial success

`_compute_batch()` set `is_healthy = True` whenever the batch produced at least one
vector. Per-item isolation can recover an early item and then meet a provider-wide
failure (exhausted quota, authentication, exhausted transient retries), which
`_call_with_retry()` correctly records as unhealthy before it stops. The recovered
vector then overwrote that state, so a request that ended in an outage reported the
provider as healthy.

The terminal-failure flag is now threaded from `_embed_individually()` back to
`_compute_batch()`. The vectors recovered before the failure are still returned and
cached; only the health state now follows the failure. This matters because
`LocalFileStore._recover_after_real_request()` schedules missing-vector backfill off
the unhealthy-to-healthy transition: a hidden outage would either trigger backfill
while the provider is still unavailable or suppress a later genuine recovery.

Adds a regression test through `get_embeddings()` that checks both the retained
partial vector and the final unhealthy state. The existing isolation test covers
`_call_with_retry()` directly, so it never saw the overwrite.

* fix(embedding): leave provider 5xx retries to the layers that own them

A store-level retry for a provider 5xx stacked on top of the retries the
embedding wrapper and the provider SDK already perform, so a batch the SDK
answered after 12 attempts became 36. Retry ownership stays with those
layers; the store answers a provider-wide 5xx once and gives up.

Transport failures keep their retry because they describe a connection that
no lower layer has reported, and the opt-in quota policy is untouched.

Tests updated to pin the new boundary: a 503 is answered once for the
OpenAI-shaped, Gemini-shaped and DashScope-shaped errors, while a 429 keeps
its retry from the rate-limit branch.

* fix(embedding): make transport retry ownership explicit

The store retried a connection failure or a timeout on top of the embedding
wrapper and the provider SDK, which already retry both, so a persistent
timeout on a batch reached the provider 36 times instead of 12: the OpenAI
model retries a batch max_retries + 1 times and the SDK retries each of those
attempts.

Transport failures stay provider-wide -- the request is never split per item
-- but the retry is now owned explicitly instead of implicitly:

* a whole-batch request leaves it to the layers below, which already retry it;
* an isolated single-item attempt keeps the store's backoff, because that
  attempt is the last chance to keep the one vector the isolation step is
  recovering.

Rate limiting is unchanged. The transport cases now pin both budgets, and
three tests drive a real OpenAIAsEmbedding wrapper and a real
openai.AsyncClient over an httpx.MockTransport, so the provider attempt count
is measured rather than assumed.

* fix(embedding): check the failing error against the provider retry policy
2026-10-21 16:32:49 +08:00
..
agent_wrapper fix(codex): retain tool constraints until threads unload (#630) 2026-10-16 16:46:02 +08:00
as_decision_model feat(decision): allow hosts to inject owned JEV model instances (#652) 2026-10-21 14:54:31 +08:00
as_embedding fix(embedding): exclude provider init from health timeout (#484) 2026-08-21 11:23:14 +08:00
as_llm feat(docker): add container deployment and multi-platform release workflow (#582) 2026-10-03 23:54:58 +08:00
client feat(service): support explicit wildcard network binds (#547) 2026-09-15 14:26:33 +08:00
embedding_store fix(embedding): keep one bad item from dropping its whole batch (#579) 2026-10-21 16:32:49 +08:00
file_catalog feat(core): support live component replacement (#505) 2026-08-28 16:04:02 +08:00
file_chunker fix(chunker): treat default file chunks as plain text (#624) 2026-10-19 12:30:35 +08:00
file_graph feat(auto_resource): interpret image resources into daily notes (#500) 2026-09-07 12:00:38 +08:00
file_store fix(embedding): keep one bad item from dropping its whole batch (#579) 2026-10-21 16:32:49 +08:00
job feat(jobs): add a unified enabled switch for all job types (#580) 2026-09-30 18:30:36 +08:00
keyword_index fix(bm25): preserve the index when batch tokenization fails (#637) 2026-10-19 17:56:52 +08:00
outbound_proxy feat: add Auto Fin cookbook and managed outbound proxy support (#392) 2026-07-25 18:09:39 +08:00
service feat(jobs): add a unified enabled switch for all job types (#580) 2026-09-30 18:30:36 +08:00
tag_index feat: auto-tag plugin-generated reports (#534) 2026-09-11 11:52:52 +08:00
tokenizer Refactor logging levels and add dream schema definitions (#291) 2026-06-24 16:44:03 +08:00
__init__.py refactor: generalize decision models and add recoverable context compaction (#626) 2026-10-19 17:56:30 +08:00
application_context.py feat: add entry-point plugin system and extract Auto Fin (#459) 2026-08-19 17:23:23 +08:00
base_component.py feat(core): support live component replacement (#505) 2026-08-28 16:04:02 +08:00
component_registry.py feat: add entry-point plugin system and extract Auto Fin (#459) 2026-08-19 17:23:23 +08:00
prompt_handler.py fix(prompt): default omitted conditional flags to false (#424) 2026-08-06 16:22:59 +08:00
runtime_context.py feat: add workspace web APIs and star growth report (#416) 2026-08-05 18:03:37 +08:00