fix(milvus): classify client-caused gRPC errors as client errors (PLU-543) - #786
Conversation
There was a problem hiding this comment.
2 issues found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="test/unit/processes/connectors/test_milvus.py">
<violation number="1" location="test/unit/processes/connectors/test_milvus.py:384">
P3: The secondary assertions after `pytest.raises` are logically guaranteed and add no signal. In `test_precheck_reclassifies_raw_grpc_error`, `pytest.raises(expected)` (UserAuthError/UserError) already pins the exact type; the subsequent `assert not isinstance(excinfo.value, DestinationConnectionError)` can never fail because these classes are disjoint siblings. The same redundancy appears in `test_precheck_missing_collection_is_user_error`, and in `test_insert_results_other_grpc_code_stays_write_error` where `assert not isinstance(excinfo.value, UserError)` follows `pytest.raises(WriteError)`. `test_run_data_missing_collection_delete_grpc_not_found_is_user_error`'s trailing `assert isinstance(excinfo.value, UnstructuredIngestError)` is trivially true for any subclass. These extra asserts verify nothing beyond the `pytest.raises` clause; remove them.</violation>
</file>
<file name="unstructured_ingest/processes/connectors/milvus.py">
<violation number="1" location="unstructured_ingest/processes/connectors/milvus.py:131">
P3: `platform_error_factory(exc)` is invoked for every failure, including client-coded ones (UNAUTHENTICATED, PERMISSION_DENIED, INVALID_ARGUMENT, NOT_FOUND) that `_classify_milvus_exception` immediately reclassifies to a UserAuthError/UserError and discards the factory result. The factory (plus a second `safe_error_summary` call on a credential-bearing exception) should only run on the fall-through branch when no client code is resolved.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
| uploader.precheck() | ||
| # UserAuthError is a UserError subclass; assert we did not fall through to a | ||
| # platform DestinationConnectionError. | ||
| assert not isinstance(excinfo.value, DestinationConnectionError) |
There was a problem hiding this comment.
P3: The secondary assertions after pytest.raises are logically guaranteed and add no signal. In test_precheck_reclassifies_raw_grpc_error, pytest.raises(expected) (UserAuthError/UserError) already pins the exact type; the subsequent assert not isinstance(excinfo.value, DestinationConnectionError) can never fail because these classes are disjoint siblings. The same redundancy appears in test_precheck_missing_collection_is_user_error, and in test_insert_results_other_grpc_code_stays_write_error where assert not isinstance(excinfo.value, UserError) follows pytest.raises(WriteError). test_run_data_missing_collection_delete_grpc_not_found_is_user_error's trailing assert isinstance(excinfo.value, UnstructuredIngestError) is trivially true for any subclass. These extra asserts verify nothing beyond the pytest.raises clause; remove them.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/unit/processes/connectors/test_milvus.py, line 384:
<comment>The secondary assertions after `pytest.raises` are logically guaranteed and add no signal. In `test_precheck_reclassifies_raw_grpc_error`, `pytest.raises(expected)` (UserAuthError/UserError) already pins the exact type; the subsequent `assert not isinstance(excinfo.value, DestinationConnectionError)` can never fail because these classes are disjoint siblings. The same redundancy appears in `test_precheck_missing_collection_is_user_error`, and in `test_insert_results_other_grpc_code_stays_write_error` where `assert not isinstance(excinfo.value, UserError)` follows `pytest.raises(WriteError)`. `test_run_data_missing_collection_delete_grpc_not_found_is_user_error`'s trailing `assert isinstance(excinfo.value, UnstructuredIngestError)` is trivially true for any subclass. These extra asserts verify nothing beyond the `pytest.raises` clause; remove them.</comment>
<file context>
@@ -154,3 +171,314 @@ def test_run_produces_empty_output_when_no_embeddings(
+ uploader.precheck()
+ # UserAuthError is a UserError subclass; assert we did not fall through to a
+ # platform DestinationConnectionError.
+ assert not isinstance(excinfo.value, DestinationConnectionError)
+
+
</file context>
| try: | ||
| yield | ||
| except (grpc.RpcError, MilvusException) as exc: | ||
| raise _classify_milvus_exception(exc, platform_error_factory(exc)) from None |
There was a problem hiding this comment.
P3: platform_error_factory(exc) is invoked for every failure, including client-coded ones (UNAUTHENTICATED, PERMISSION_DENIED, INVALID_ARGUMENT, NOT_FOUND) that _classify_milvus_exception immediately reclassifies to a UserAuthError/UserError and discards the factory result. The factory (plus a second safe_error_summary call on a credential-bearing exception) should only run on the fall-through branch when no client code is resolved.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At unstructured_ingest/processes/connectors/milvus.py, line 131:
<comment>`platform_error_factory(exc)` is invoked for every failure, including client-coded ones (UNAUTHENTICATED, PERMISSION_DENIED, INVALID_ARGUMENT, NOT_FOUND) that `_classify_milvus_exception` immediately reclassifies to a UserAuthError/UserError and discards the factory result. The factory (plus a second `safe_error_summary` call on a credential-bearing exception) should only run on the fall-through branch when no client code is resolved.</comment>
<file context>
@@ -31,11 +34,103 @@
+ try:
+ yield
+ except (grpc.RpcError, MilvusException) as exc:
+ raise _classify_milvus_exception(exc, platform_error_factory(exc)) from None
+
+
</file context>
…-543) Bad or expired customer Milvus credentials surface a gRPC UNAUTHENTICATED status, and permission/bad-request/missing-resource failures surface PERMISSION_DENIED / INVALID_ARGUMENT / NOT_FOUND. The uploader caught these as MilvusException and re-raised them as platform-class errors (WriteError in insert_results, DestinationConnectionError in precheck), so a single customer's misconfiguration burned the platform DAG Job Completions SLO (org 315209346834, job job-a7f10a87: ~25k UNAUTHENTICATED over 24h). Add _classify_milvus_exception(): when a transport-level gRPC failure re-raises MilvusException(e.code(), ...) with a grpc.StatusCode, map UNAUTHENTICATED -> UserAuthError (401) and PERMISSION_DENIED / INVALID_ARGUMENT / NOT_FOUND -> UserError (422); anything else (server-side business codes, unknown failures) falls through to the existing platform class. Wired into both precheck() and insert_results(). precheck() drops the @DestinationConnectionError.wrap decorator: that wrapper re-wraps every escaping exception into a platform DestinationConnectionError, which would clobber the reclassification. Its catch-all fallback is reproduced inline so non-Milvus errors still surface as platform errors. Unit tests cover each reclassified status plus fall-through for an unrelated gRPC code and a server-side int error code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(PLU-543) The prior fix only caught MilvusException, but pymilvus 2.6.9 re-raises the customer-credential codes (UNAUTHENTICATED, PERMISSION_DENIED, INVALID_ARGUMENT -- everything in decorators.IGNORE_RETRY_CODES) as the RAW grpc.RpcError via `raise e from e`, never wrapped. So the whole motivating case (UNAUTHENTICATED) never reached the classifier and stayed platform-classed. Rework: - _grpc_status_code() resolves the grpc.StatusCode from BOTH a raw grpc.RpcError (.code() method) and a MilvusException (.code property, including the sync retry-storm path where pymilvus stores the raw .code method object). Only a resolved grpc.StatusCode reclassifies; int server ErrorCodes fall through to the platform error. - _reclassify_milvus_errors() context manager catches (grpc.RpcError, MilvusException) and is wired into precheck(), insert_results(), delete_by_record_id(), and _prepare_data_for_insert(). The missing-collection branch of precheck() now raises UserError, not a platform DestinationConnectionError. - Tests replaced: drive precheck/insert/delete/prepare through a mocked client raising a REAL grpc.RpcError subclass whose .code() returns the target StatusCode (exactly what pymilvus re-raises), asserting UserAuthError/ UserError; plus a MilvusException-wrapped path and the callable-code retry-storm path. The earlier synthetic MilvusException(UNAUTHENTICATED) tests validated a shape pymilvus never emits and are gone. - CI: add pymilvus to the `test` dependency group so `uv sync --group test --locked` installs it (the tests import pymilvus/grpc); the unit-test job does not pass --all-extras. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_client() issues using_database() on __enter__ when db_name is set. At the three write sites (delete_by_record_id, _prepare_data_for_insert, insert_results) it was entered before _reclassify_milvus_errors, so an UNAUTHENTICATED / PERMISSION_DENIED there escaped unclassified as a raw grpc.RpcError. Enter the reclassify context first, matching precheck. precheck already covered the common bad-creds-at-job-start case; this closes the residual mid-job token-expiry / per-operation-permission window at the write sites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_reclassify_milvus_errors re-raised the classified error `from exc`, chaining the raw grpc.RpcError/MilvusException (server free-text, debug_error_string) back into the traceback that the connector redaction series (CHANGELOG 1.6.31 / 1.7.8) scrubs with `from None`. logger.exception / exc_info on the raised error would resurface that raw provider text. Re-raise `from None` to match the inline precheck fallback and the wrap-decorator behavior; add a regression test asserting no __cause__ chain and no raw text in the rendered traceback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bb5e668 to
7667eb1
Compare
What & why
A Milvus destination given bad or expired customer credentials failed the job with a platform error, counting a customer misconfiguration against the Job Completions SLO. The fix reclassifies the customer-caused gRPC failures as client errors (
UserAuthError-> 401,UserError-> 422) so they stop burning the SLO.The subtlety that makes this real is how pymilvus surfaces the failure. Verified against the pinned
pymilvus==2.6.9(pymilvus/decorators.py):decorators.IGNORE_RETRY_CODES—UNAUTHENTICATED,PERMISSION_DENIED,INVALID_ARGUMENT(plusDEADLINE_EXCEEDED/ALREADY_EXISTS/RESOURCE_EXHAUSTED/UNIMPLEMENTED) — are re-raised as the RAWgrpc.RpcErrorviaraise e from e(decorators.py:216 and :294). They are never wrapped in aMilvusException.grpc.RpcErrorcarries its status on a.code()method.NOT_FOUND, not inIGNORE_RETRY_CODES) is retried and, once the retry budget is exhausted, wrapped intoMilvusException(e.code, ...).MilvusExceptioncarries its status on a.codeproperty (which for a genuine server-side business failure is an intErrorCode, not agrpc.StatusCode).So the entire customer-credential set — crucially
UNAUTHENTICATED, the whole motivation for this ticket — reaches the connector as a rawgrpc.RpcError, not aMilvusException. Any classifier orexceptclause that only looks atMilvusExceptionnever sees it, and the failure stays platform-classed. The classifier therefore resolves thegrpc.StatusCodefrom both exception types and both call paths.Linked ticket
PLU-543
Impact
UserAuthError-> 401, orUserError-> 422) instead of a platform error. The failure is attributed to them, not to us. Users with valid Milvus config and any genuine server-side (non-client) Milvus failure are unaffected — those still raise the platform error as before.UNAUTHENTICATED->UserAuthError, andPERMISSION_DENIED/INVALID_ARGUMENT/NOT_FOUND->UserError, the exception type raised from the uploader changes. This is wired into every customer-facing Milvus call:precheck(),insert_results(),delete_by_record_id(), and_prepare_data_for_insert()(thedescribe_collectionschema round-trip). Thehas_collection() == Falsebranch ofprecheck()— a missing target collection — now raisesUserErrorinstead of a platformDestinationConnectionError, since an absent collection is the customer's configuration, not a platform fault. Every other code, and any non-Milvus/non-gRPC failure, still raisesDestinationConnectionError/WriteErrorexactly as before.pymilvusis added to thetestdependency group — see below.) Not exercised on DI.Risk / rollback
Low. Pure code change, no migration or data touched — revert the PR to back it out. The one subtlety is that
precheck()drops its@DestinationConnectionError.wrapdecorator (see below); the decorator's catch-all fallback is reproduced inline, so any non-Milvus exception escaping precheck still becomes aDestinationConnectionError.How it was verified
Unit tests drive REAL
grpc.RpcError. The tests build agrpc.RpcErrorsubclass whose.code()returns the targetgrpc.StatusCode— exactly the object pymilvus 2.6.9 re-raises — and push it throughprecheck(),insert_results(),delete_by_record_id(), and_prepare_data_for_insert()via a mocked Milvus client, asserting the result isUserAuthError(401) /UserError(422). AMilvusException-wrapped path (NOT_FOUND) and the sync retry-storm path (pymilvus stores the raw.codemethod on theMilvusException) are covered too, plus a control that a server-sideINTERNALstays platform. The earlier tests used a syntheticMilvusException(code=UNAUTHENTICATED)— a shape pymilvus never emits for that code — and validated a wrong assumption; they are replaced. 29 tests pass (pytest test/unit/processes/connectors/test_milvus.py).CI extra fix. The tests import
pymilvus/grpc, butmilvusis an optional extra and the unit-test job runsuv sync --group test --lockedwithout--all-extras, so the tests would have errored red in CI.pymilvusis added to thetestdependency group (matching how the repo already lists other connectors' test deps there), anduv.lockis regenerated;uv tree --only-group testconfirmspymilvus v2.6.9 (group: test), so the job installs it.SND reproof on
pk-ab-lane-bwith the REAL exception. A uploader overlay image was built at this commit (FROM platform-plugins-uploader:3.6.2+ the fixedmilvus.py), pushed to ACR, and run against a harness that raises a REALgrpc.RpcError(not a syntheticMilvusException). Baseline (stock3.6.2) vs fixed (plu543-249b0241):3.6.2Baseline never yields a client error for the customer-cred codes — precheck's old
@wrapflattens it to a platform 400, and insert/delete let the rawgrpc.RpcErrorescape entirely because the old code caught onlyMilvusException. The fixed image classifies every customer-cred code correctly while the server-sideINTERNALcontrol stays platform 400. The image was applied viaETL_NODE_IMAGE.uploader.default.tagin ConfigMapjob-execution-config(the DTPL-468 lever), then the ConfigMap was reverted to the pristine3.6.2tag.ruff
checkandformat --checkclean (repo-pinned ruff 0.15.1) on the changed files.The
@DestinationConnectionError.wrapremoval (why it is load-bearing)_classify_milvus_exception(exc, platform_error)resolves thegrpc.StatusCodeoff either a rawgrpc.RpcErroror aMilvusException(via_grpc_status_code, which handles the method/property/int/callable variants) and returns a client error for the customer-caused codes, otherwise returns the passed-inplatform_errorunchanged. A_reclassify_milvus_errorscontext manager catches(grpc.RpcError, MilvusException)and runs the block through the classifier; it is wired intoprecheck(),insert_results(),delete_by_record_id(), and_prepare_data_for_insert().For
precheck()the@DestinationConnectionError.wrapclass decorator had to go: it re-wraps every escaping exception into aDestinationConnectionError, which would immediately clobber theUserAuthError/UserErrorthis change raises. So precheck now handles its own exceptions: the reclassify context manager handles the Milvus/gRPC failures, an already-classifiedUnstructuredIngestError(including the missing-collectionUserError) re-raises untouched, and the decorator's blanket fallback is reproduced inline (except Exception -> DestinationConnectionError) so nothing that used to become a platform error stops doing so.Note for the reviewer: this branch was developed in a dedicated
unstructured-ingest-plu543worktree because the primaryunstructured-ingestcheckout sits on an unrelated, divergentpk/plu-370branch. The branch here is cut clean fromorigin/main.