Skip to content

Commit 89d08f1

Browse files
SoulPancakerhamzeh
andauthored
feat: support write conflicts options (#237)
* feat: implementation along with tests * feat: support write conflict idempotency * fix : tests for default on missing and on dupl * fix: tests fix and ruff fix * feat: add readme updates for write conflict changes * feat: changelog and readme changes * fix: lint errors and update from sdk generator * fix: apply suggestions from code review Co-authored-by: Raghd Hamzeh <[email protected]> * feat: remove API options from readme * feat: add all changes to tests and client * feat: address comments, pass settings in opts * feat: condense readme text and give options * feat: reformat readme.md * feat: minor fmt * feat: lint fix add commas * fix: value error f string * feat: backward compatibility --------- Co-authored-by: Raghd Hamzeh <[email protected]>
1 parent 94e817a commit 89d08f1

20 files changed

+1234
-395
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# Changelog
22

33
## [Unreleased](https://github.com/openfga/python-sdk/compare/v0.9.7...HEAD)
4+
- feat: add support for conflict options for Write operations: (#235)
5+
The client now supports setting `ConflictOptions` on `ClientWriteOptions` to control behavior when writing duplicate tuples or deleting non-existent tuples. This feature requires OpenFGA server [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later.
6+
See [Conflict Options for Write Operations](./README.md#conflict-options-for-write-operations) for more.
7+
- `on_duplicate` for handling duplicate tuple writes (ERROR or IGNORE)
8+
- `on_missing` for handling deletes of non-existent tuples (ERROR or IGNORE)
9+
- docs: added documentation for write conflict options in README
410

511
### [0.9.7](https://github.com/openfga/python-sdk/compare/v0.9.6...0.9.7) (2025-10-06)
612

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,49 @@ body = ClientWriteRequest(
748748
response = await fga_client.write(body, options)
749749
```
750750

751+
###### Conflict Options for Write Operations
752+
753+
OpenFGA v1.10.0+ supports conflict options for write operations to handle duplicate writes and missing deletes gracefully.
754+
755+
**Example: Ignore duplicate writes and missing deletes**
756+
757+
```python
758+
# from openfga_sdk import OpenFgaClient
759+
# from openfga_sdk.client.models import ClientTuple, ClientWriteRequest
760+
# from openfga_sdk.client.models.write_conflict_opts import (
761+
# ClientWriteRequestOnDuplicateWrites,
762+
# ClientWriteRequestOnMissingDeletes,
763+
# ConflictOptions,
764+
# )
765+
766+
options = {
767+
"authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1",
768+
"conflict": ConflictOptions(
769+
on_duplicate_writes=ClientWriteRequestOnDuplicateWrites.IGNORE, # Available options: ERROR, IGNORE
770+
on_missing_deletes=ClientWriteRequestOnMissingDeletes.IGNORE, # Available options: ERROR, IGNORE
771+
)
772+
}
773+
774+
body = ClientWriteRequest(
775+
writes=[
776+
ClientTuple(
777+
user="user:81684243-9356-4421-8fbf-a4f8d36aa31b",
778+
relation="viewer",
779+
object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a",
780+
),
781+
],
782+
deletes=[
783+
ClientTuple(
784+
user="user:81684243-9356-4421-8fbf-a4f8d36aa31b",
785+
relation="writer",
786+
object="document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a",
787+
),
788+
],
789+
)
790+
791+
response = await fga_client.write(body, options)
792+
```
793+
751794
#### Relationship Queries
752795

753796
##### Check

docs/OpenFgaApi.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1291,7 +1291,7 @@ No authorization required
12911291
12921292
Add or delete tuples from the store
12931293

1294-
The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ```
1294+
The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ```
12951295

12961296
### Example
12971297

docs/WriteRequestDeletes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Name | Type | Description | Notes
66
------------ | ------------- | ------------- | -------------
77
**tuple_keys** | [**list[TupleKeyWithoutCondition]**](TupleKeyWithoutCondition.md) | |
8+
**on_missing** | **str** | On &#39;error&#39;, the API returns an error when deleting a tuple that does not exist. On &#39;ignore&#39;, deletes of non-existent tuples are treated as no-ops. | [optional] [default to 'error']
89

910
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
1011

docs/WriteRequestWrites.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Name | Type | Description | Notes
66
------------ | ------------- | ------------- | -------------
77
**tuple_keys** | [**list[TupleKey]**](TupleKey.md) | |
8+
**on_duplicate** | **str** | On &#39;error&#39; ( or unspecified ), the API returns an error if an identical tuple already exists. On &#39;ignore&#39;, identical writes are treated as no-ops (matching on user, relation, object, and RelationshipCondition). | [optional] [default to 'error']
89

910
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
1011

openfga_sdk/api/open_fga_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2667,7 +2667,7 @@ async def streamed_list_objects_with_http_info(self, body, **kwargs):
26672667
async def write(self, body, **kwargs):
26682668
"""Add or delete tuples from the store
26692669
2670-
The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ```
2670+
The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ```
26712671
26722672
>>> thread = await api.write(body)
26732673
@@ -2694,7 +2694,7 @@ async def write(self, body, **kwargs):
26942694
async def write_with_http_info(self, body, **kwargs):
26952695
"""Add or delete tuples from the store
26962696
2697-
The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ```
2697+
The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ```
26982698
26992699
>>> thread = api.write_with_http_info(body)
27002700

openfga_sdk/client/client.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,17 @@ def options_to_transaction_info(
134134
return WriteTransactionOpts()
135135

136136

137+
def options_to_conflict_info(
138+
options: dict[str, int | str | dict[str, int | str]] | None = None,
139+
):
140+
"""
141+
Return the conflict info
142+
"""
143+
if options is not None and options.get("conflict"):
144+
return options["conflict"]
145+
return None
146+
147+
137148
def _check_errored(response: ClientBatchCheckClientResponse):
138149
"""
139150
Helper function to return whether the response is errored
@@ -520,12 +531,23 @@ async def _write_with_transaction(
520531
Write or deletes tuples
521532
"""
522533
kwargs = options_to_kwargs(options)
534+
conflict_options = options_to_conflict_info(options)
535+
536+
# Extract conflict options to pass to the tuple key methods
537+
on_duplicate = None
538+
on_missing = None
539+
if conflict_options:
540+
if conflict_options.on_duplicate_writes:
541+
on_duplicate = conflict_options.on_duplicate_writes.value
542+
if conflict_options.on_missing_deletes:
543+
on_missing = conflict_options.on_missing_deletes.value
544+
523545
writes_tuple_keys = None
524546
deletes_tuple_keys = None
525-
if body.writes_tuple_keys:
526-
writes_tuple_keys = body.writes_tuple_keys
527-
if body.deletes_tuple_keys:
528-
deletes_tuple_keys = body.deletes_tuple_keys
547+
if body.writes:
548+
writes_tuple_keys = body.get_writes_tuple_keys(on_duplicate=on_duplicate)
549+
if body.deletes:
550+
deletes_tuple_keys = body.get_deletes_tuple_keys(on_missing=on_missing)
529551

530552
await self._api.write(
531553
WriteRequest(

openfga_sdk/client/models/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
from openfga_sdk.client.models.list_relations_request import ClientListRelationsRequest
1515
from openfga_sdk.client.models.read_changes_request import ClientReadChangesRequest
1616
from openfga_sdk.client.models.tuple import ClientTuple
17+
from openfga_sdk.client.models.write_conflict_opts import (
18+
ClientWriteRequestOnDuplicateWrites,
19+
ClientWriteRequestOnMissingDeletes,
20+
ConflictOptions,
21+
)
22+
from openfga_sdk.client.models.write_options import ClientWriteOptions
1723
from openfga_sdk.client.models.write_request import ClientWriteRequest
1824
from openfga_sdk.client.models.write_response import ClientWriteResponse
1925
from openfga_sdk.client.models.write_transaction_opts import WriteTransactionOpts
@@ -35,4 +41,8 @@
3541
"ClientWriteRequest",
3642
"ClientWriteResponse",
3743
"WriteTransactionOpts",
44+
"ClientWriteRequestOnDuplicateWrites",
45+
"ClientWriteRequestOnMissingDeletes",
46+
"ConflictOptions",
47+
"ClientWriteOptions",
3848
]
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Python SDK for OpenFGA
3+
4+
API version: 1.x
5+
Website: https://openfga.dev
6+
Documentation: https://openfga.dev/docs
7+
Support: https://openfga.dev/community
8+
License: [Apache-2.0](https://github.com/openfga/python-sdk/blob/main/LICENSE)
9+
10+
NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT.
11+
"""
12+
13+
from enum import Enum
14+
15+
16+
class ClientWriteRequestOnDuplicateWrites(str, Enum):
17+
ERROR = "error"
18+
IGNORE = "ignore"
19+
20+
21+
class ClientWriteRequestOnMissingDeletes(str, Enum):
22+
ERROR = "error"
23+
IGNORE = "ignore"
24+
25+
26+
class ConflictOptions:
27+
"""
28+
OpenFGA client write conflict options
29+
"""
30+
31+
def __init__(
32+
self,
33+
on_duplicate_writes: ClientWriteRequestOnDuplicateWrites | None = None,
34+
on_missing_deletes: ClientWriteRequestOnMissingDeletes | None = None,
35+
) -> None:
36+
self._on_duplicate_writes = on_duplicate_writes
37+
self._on_missing_deletes = on_missing_deletes
38+
39+
@property
40+
def on_duplicate_writes(self) -> ClientWriteRequestOnDuplicateWrites | None:
41+
"""
42+
Return on_duplicate_writes
43+
"""
44+
return self._on_duplicate_writes
45+
46+
@on_duplicate_writes.setter
47+
def on_duplicate_writes(
48+
self,
49+
value: ClientWriteRequestOnDuplicateWrites | None,
50+
) -> None:
51+
"""
52+
Set on_duplicate_writes
53+
"""
54+
self._on_duplicate_writes = value
55+
56+
@property
57+
def on_missing_deletes(self) -> ClientWriteRequestOnMissingDeletes | None:
58+
"""
59+
Return on_missing_deletes
60+
"""
61+
return self._on_missing_deletes
62+
63+
@on_missing_deletes.setter
64+
def on_missing_deletes(
65+
self,
66+
value: ClientWriteRequestOnMissingDeletes | None,
67+
) -> None:
68+
"""
69+
Set on_missing_deletes
70+
"""
71+
self._on_missing_deletes = value

0 commit comments

Comments
 (0)