Closes #20054: Return per-object error details for failed bulk operations#22646
Closes #20054: Return per-object error details for failed bulk operations#22646bctiemann wants to merge 6 commits into
Conversation
…ions
Bulk update (PATCH), sequential bulk create (POST), and bulk delete (DELETE) on
list endpoints now collect per-object errors instead of aborting on the first
failure. When any objects fail, the entire operation is rolled back atomically
and a 400/409 response is returned with a structured payload:
{
"detail": "1 of 3 objects failed validation.",
"results": [
{"id": 1, "status": "ok"},
{"id": 2, "status": "error", "errors": {"name": ["..."]}},
{"id": 3, "status": "ok"}
]
}
For bulk creates via SequentialBulkCreatesMixin the correlator is "index"
(zero-based position in the request list) since no IDs exist yet. For bulk
delete the status code remains 409 and the correlator is "id".
Successful operations are unchanged (200/201/204).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use pre-captured `pk` consistently in perform_bulk_destroy error path - Add comment clarifying the `if results:` sentinel in bulk_update - Add per-field atomicity assertion to test_bulk_update_objects_validation_error - Use ID-keyed dict instead of positional index in test_bulk_delete_objects_protected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… case
Use a valid first item (create_data[0]) alongside an invalid second item ({})
so the test exercises both the 'ok' result shape and the atomic rollback of an
item that would otherwise have been persisted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@claude /review and report only on areas of potential concern. |
|
Claude finished @bctiemann's task in 3m 22s —— View job Code Review
The implementation is well-structured overall. Below are specific areas that may warrant attention before merging. 1. Potential information disclosure in bulk delete error response (security)File: protected = list(
e.protected_objects if isinstance(e, ProtectedError) else e.restricted_objects
)
n = len(protected)
objects_str = ', '.join(f'{o} ({o.pk})' for o in protected[:10])The error response includes For example: a user with Consider stripping the per-object details from the error message (e.g., only report the count and type), or gating inclusion on a permission check. Fix this → 2.
|
- Security: remove object names/PKs from ProtectedError detail; report count only to avoid exposing objects the caller may lack permission to view - i18n: wrap new error detail strings with _().format() to match codebase convention - Redundancy: remove superfluous `results and` guard in bulk_destroy (any() on an empty list already returns False) - Comment: explain that SequentialBulkCreatesMixin continues provisionally creating after a failure so cross-object validators see a realistic state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Addressed items 1, 3, 4, 5. Item 2 is fine as-is; "ok" is better semantics than something like "valid" which just gets confusing. |
- Move single-object create back inside transaction.atomic() (comment 1) - Replace repeated result-list iterations with local error_count counters in create(), perform_bulk_update(), and perform_bulk_destroy() (comments 3, 4, 6) - Rewrite perform_bulk_update() from two-pass (validate-all, save-all) to sequential per-object validate+save, matching SequentialBulkCreatesMixin; subsequent validators now see DB state from prior saves so cross-object uniqueness conflicts are caught at validation time (comment 5) - Update bulk_update() and bulk_destroy() callers to unpack new return tuples and use the counters directly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Success is now inferred from the absence of an errors key, matching
Jeremy's suggestion. Error entries carry only {id/index, errors};
successful entries carry only {id/index}. Update all tests accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Updated PR description according to new payload. |
| if serializer.is_valid(): | ||
| self.perform_update(serializer) | ||
| updated_pks.append(obj.pk) | ||
| results.append({'id': obj.pk}) |
There was a problem hiding this comment.
With the removal of the explicit status field, it probably makes sense to omit successful objects entirely, and return only the objects with errors associated. Similarly, it probably makes sense to rename results to errors. This also obviates the need for a separate error counter: len(errors) is sufficient.
| results.append({ | ||
| 'id': pk, | ||
| 'errors': { | ||
| 'detail': _( |
There was a problem hiding this comment.
Can we tweak this a bit to better match the format of errors returned by creates and updates? (These are field-based.)
| 'detail': _( | |
| '__all__': _( |
| # Report only the count — not names or PKs — to avoid exposing objects | ||
| # the caller may not have permission to view. |
There was a problem hiding this comment.
This differs from the single-object deletion endpoint, which does convey the specific objects preventing deletion. If this is a genuine security concern, the detail endpoint should be modified to match this behavior. (This would require a separate bug report.) If it's merely a performance concern, the comment should be corrected.
| # All creates are rolled back together if any item in the batch fails. | ||
| self.perform_create(serializer) | ||
| return_data.append(serializer.data) | ||
| results.append({'index': i}) |
There was a problem hiding this comment.
Suggest omitting this as under perform_bulk_update()
| results.append({'index': i}) |
| return_data.append(serializer.data) | ||
|
|
||
| headers = self.get_success_headers(serializer.data) | ||
| if serializer.is_valid(): |
There was a problem hiding this comment.
Removing raise_exception=True appears to no longer catch certain uniqueness violations. For example, try creating two sites with the same name & slug:
POST /api/dcim/sites/
[
{"name": "Site 1", "slug": "site-1"},
{"name": "Site 1", "slug": "site-1"}
]
On the current release, this returns a clean validation failure:
{
"error": "duplicate key value violates unique constraint \"dcim_site_name_key\"\nDETAIL: Key (name)=(Site 1) already exists.",
"exception": "IntegrityError",
"netbox_version": "4.6.4",
"python_version": "3.12.3"
}However, the PR no longer catches the IntegrityError exception:
IntegrityError at /api/dcim/sites/
duplicate key value violates unique constraint "dcim_site_name_key"
DETAIL: Key (name)=(Site 1) already exists.
Although it limits the utility of conveying errors for multiple objects in some scenarios, the safer path IMO would be to continue raising exceptions to trigger an immediate failure on the first such error. This improvement is incidental to the PR's primary focus anyway.
Closes: #20054
Summary
Bulk write endpoints now return a structured, per-object error payload instead of stopping at the first failure. The entire batch is still rolled back atomically when any object fails, but the response identifies exactly which objects failed and why — enabling clients to correct and retry the specific failing items without guessing.
Response shape (error case)
{ "detail": "1 of 3 objects failed validation.", "results": [ {"id": 1}, {"id": 2, "errors": {"name": ["This field is required."]}}, {"id": 3} ] }For bulk creates via
SequentialBulkCreatesMixinthe correlator is"index"(zero-based position in the request list, since no IDs exist yet). For bulk deletes the status code remains 409.Operations changed
PATCH(bulk update)BulkUpdateModelMixinresultsPOST(bulk create)SequentialBulkCreatesMixinresultsDELETE(bulk delete)BulkDestroyModelMixinresultsBreaking change: the bulk delete error response body has changed from a bare
{"detail": "..."}string to the structured format above.Implementation notes
perform_bulk_update: two-pass — validate all objects first (no writes), then update all if all pass;set_rollback(True)on any failureSequentialBulkCreatesMixin.create: sequential validate-and-create (preserves rack-space and other cross-object validators), collect errors,set_rollback(True)on any failureBulkDestroyModelMixin.perform_bulk_destroy: catchesProtectedError/RestrictedErrorper object;set_rollback(True)rolls back any partial deletesrollback()is always called by exiting thewith transaction.atomic()block normally (never viareturn-from-inside), soTransactionManagementErroris not raised in productionTest plan
SiteTestCase.test_bulk_update_objects_validation_error— mixed valid/invalid PATCH returns 400 with per-object results; object 0 passes validation, object 1 failsSiteTestCase.test_bulk_delete_objects_protected— bulk DELETE where one site has a dependent Device; 409 response with per-object results, both sites still exist (rollback verified)DeviceTestCase.test_bulk_create_objects_validation_error— empty objects viaSequentialBulkCreatesMixin; 400 with index-correlated results, no objects createdDeviceTestCase.test_rack_fit— sequential rack-space validation preserved (overlapping bulk create → 400)dcim.tests.test_apisuite — 1146 tests, 44 skipped, 0 failuresipam.tests.test_api+extras.tests.test_api— pass without changes🤖 Generated with Claude Code