10.0: gRPC upsert_from_dataframe reports partial failures instead of raising

In 9.1 and earlier, GrpcIndex.upsert_from_dataframe raised as soon as any batch failed, and the exception did not say how many rows had landed. From 10.0 it returns the partial result instead, as upsert(batch_size=...) and the REST transport already did.

What changed

import pandas as pd

# 9.1 and earlier, gRPC
try:
    index.upsert_from_dataframe(df)
except Exception:
    # No way to tell how much landed. Re-run the whole frame.
    ...

# 10.0, gRPC and REST
response = index.upsert_from_dataframe(df)
if response.failed_item_count:
    for error in response.errors:
        print(error.error_message)
    index.upsert_from_dataframe(pd.DataFrame(response.failed_items))

UpsertResponse already carried upserted_count, failed_item_count, errors and failed_items; the gRPC transport now fills them in.

Retrying failures safely

Do not retry in an unbounded loop such as while response.failed_items: .... During an outage it re-sends the frame back to back with no pause. Cap the attempts, back off between them, and re-send only retryable failures:

import time

import pandas as pd

remaining = df
for attempt in range(5):
    response = index.upsert_from_dataframe(remaining)
    retryable = [
        item
        for err in response.errors
        if err.retryable
        for item in err.items
    ]
    if not retryable:
        break
    remaining = pd.DataFrame(retryable)
    time.sleep(2**attempt)
else:
    raise RuntimeError(f"{len(remaining)} rows still failing after 5 attempts")

A batch rejected for a deterministic reason, such as a 400, fails the same way on every attempt, which is why the loop filters on err.retryable. err.disposition says how each batch failed (for example "rejected", "unsent" or "abandoned"; treat the set as open). "abandoned" means the SDK concluded the backend was unavailable and stopped sending, so wait before retrying.

The SDK already retries and backs off within a single call; the cap across calls is yours to set. When total_timeout expires, rows that were never sent also appear in failed_items, so pairing total_timeout with an unbounded retry loop re-sends on a fixed period without end.

If you depended on the raise

Pass on_error="raise":

index.upsert_from_dataframe(df, on_error="raise")

This re-raises the lowest-indexed batch failure, as 9.1 did, but only after every batch has settled, and it attaches the partial result to the exception:

try:
    index.upsert_from_dataframe(df, on_error="raise")
except Exception as exc:
    print(exc.response.upserted_count)
    retry_these = exc.response.failed_items

on_error="raise" is a supported choice for fail-fast ingest on both transports, not a temporary shim.

Finding out at runtime

The first time a gRPC ingest hits a partial failure without an explicit on_error, the SDK warns once per process, naming response.errors and on_error="raise". Passing on_error explicitly, with either value, silences the warning. REST does not warn, because its behavior did not change.