This is an automated email from the ASF dual-hosted git repository.

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new e5301b00b8 [#12207][#13482] fix(client): Handle conflict and 
unauthorized errors (#13474)
e5301b00b8 is described below

commit e5301b00b8cbcf67e078e5486fbf669d113bf82b
Author: Qi Yu <[email protected]>
AuthorDate: Wed Sep 23 23:43:08 2026 +0800

    [#12207][#13482] fix(client): Handle conflict and unauthorized errors 
(#13474)
    
    ### What changes were proposed in this pull request?
    
    - Map optimistic-lock conflict code `1012` to `OptimisticLockException`
    in the Java and Python clients, with handler tests and retry guidance.
    - Map REST unauthorized code `1011` to the existing Python
    `UnauthorizedException`. Let specialized Python handlers forward
    unfamiliar codes to the common handler instead of raising `ValueError`.
    
    ### Why are the changes needed?
    
    Concurrent metadata changes currently surface as generic REST errors,
    preventing callers from handling optimistic-lock conflicts explicitly. A
    server authentication failure returns `1011`, but the Python client can
    raise `ValueError` while converting that code to an enum.
    
    Fixed: #12207
    Fixed: #13482
    
    ### Does this PR introduce _any_ user-facing change?
    
    Java and Python callers can catch `OptimisticLockException` for code
    `1012`. Python callers can catch `UnauthorizedException` for code
    `1011`; unfamiliar server error codes fall back to `RESTException` in
    the affected handlers.
    
    ### How was this patch tested?
    
    - `./gradlew :clients:client-java:test --tests
    org.apache.gravitino.client.TestErrorHandlers -PskipITs`
    - Python `test_error_handler` suite: 19 tests passed, including an HTTP
    401 response and codes `1011`, `1012`, and `1999`.
    - Related Python table, view, and statistics client suites: 21 tests
    passed.
    - Verified all 21 Python REST handlers for codes `1011`, `1012`, and
    `1999`; Python Black and `git diff --check` passed.
---
 .../org/apache/gravitino/client/ErrorHandlers.java |   4 +
 .../apache/gravitino/client/TestErrorHandlers.java |  59 +++++++++++
 clients/client-python/gravitino/constants/error.py |  10 ++
 clients/client-python/gravitino/exceptions/base.py |   4 +
 .../exceptions/handlers/partition_error_handler.py |  14 +--
 .../handlers/statistics_error_handler.py           |   3 +-
 .../exceptions/handlers/table_error_handler.py     |  16 +--
 .../exceptions/handlers/view_error_handler.py      |  16 +--
 .../tests/unittests/test_error_handler.py          | 118 +++++++++++++++++++++
 docs/how-to-use-gravitino-client.md                |  20 ++++
 10 files changed, 240 insertions(+), 24 deletions(-)

diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
index 7788a1d44c..7357492245 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
@@ -73,6 +73,7 @@ import 
org.apache.gravitino.exceptions.NonEmptyMetalakeException;
 import org.apache.gravitino.exceptions.NonEmptySchemaException;
 import org.apache.gravitino.exceptions.NotFoundException;
 import org.apache.gravitino.exceptions.NotInUseException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.exceptions.PartitionAlreadyExistsException;
 import org.apache.gravitino.exceptions.PolicyAlreadyAssociatedException;
 import org.apache.gravitino.exceptions.PolicyAlreadyExistsException;
@@ -1423,6 +1424,9 @@ public class ErrorHandlers {
 
     @Override
     public void accept(ErrorResponse errorResponse) {
+      if (errorResponse.getCode() == 
ErrorConstants.OPTIMISTIC_LOCK_CONFLICT_CODE) {
+        throw new OptimisticLockException("%s", 
formatErrorMessage(errorResponse));
+      }
       if (errorResponse.getCode() == ErrorConstants.CONNECTION_FAILED_CODE) {
         throw new ConnectionFailedException("%s", 
formatErrorMessage(errorResponse));
       }
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestErrorHandlers.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestErrorHandlers.java
new file mode 100644
index 0000000000..0416faca8c
--- /dev/null
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestErrorHandlers.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.client;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests REST error handling for optimistic-lock conflicts. */
+public class TestErrorHandlers {
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testOptimisticLockConflictAcrossHandlers() throws 
ReflectiveOperationException {
+    ErrorResponse response =
+        ErrorResponse.optimisticLockConflict(
+            OptimisticLockException.class.getSimpleName(), "Concurrent 
update", null);
+    List<Method> factories =
+        Arrays.stream(ErrorHandlers.class.getDeclaredMethods())
+            .filter(method -> Modifier.isPublic(method.getModifiers()))
+            .filter(method -> Modifier.isStatic(method.getModifiers()))
+            .filter(method -> method.getName().endsWith("ErrorHandler"))
+            .filter(method -> method.getParameterCount() == 0)
+            .filter(method -> 
Consumer.class.isAssignableFrom(method.getReturnType()))
+            .collect(Collectors.toList());
+    Assertions.assertFalse(factories.isEmpty());
+
+    for (Method factory : factories) {
+      Consumer<ErrorResponse> handler = (Consumer<ErrorResponse>) 
factory.invoke(null);
+      OptimisticLockException exception =
+          Assertions.assertThrows(
+              OptimisticLockException.class, () -> handler.accept(response), 
factory.getName());
+      Assertions.assertEquals("Concurrent update", exception.getMessage(), 
factory.getName());
+    }
+  }
+}
diff --git a/clients/client-python/gravitino/constants/error.py 
b/clients/client-python/gravitino/constants/error.py
index 27e6031451..c63b8a9d09 100644
--- a/clients/client-python/gravitino/constants/error.py
+++ b/clients/client-python/gravitino/constants/error.py
@@ -29,6 +29,8 @@ from gravitino.exceptions.base import (
     ForbiddenException,
     NotInUseException,
     InUseException,
+    OptimisticLockException,
+    UnauthorizedException,
 )
 
 
@@ -68,6 +70,12 @@ class ErrorConstants(IntEnum):
     # Error codes for drop an in use entity.
     IN_USE_CODE = 1010
 
+    # Error codes for unauthorized access.
+    UNAUTHORIZED_CODE = 1011
+
+    # Error codes for optimistic-lock conflicts.
+    OPTIMISTIC_LOCK_CONFLICT_CODE = 1012
+
     # Error codes for invalid state.
     UNKNOWN_ERROR_CODE = 1100
 
@@ -84,6 +92,8 @@ EXCEPTION_MAPPING = {
     ForbiddenException: ErrorConstants.FORBIDDEN_CODE,
     NotInUseException: ErrorConstants.NOT_IN_USE_CODE,
     InUseException: ErrorConstants.IN_USE_CODE,
+    OptimisticLockException: ErrorConstants.OPTIMISTIC_LOCK_CONFLICT_CODE,
+    UnauthorizedException: ErrorConstants.UNAUTHORIZED_CODE,
 }
 
 ERROR_CODE_MAPPING = {v: k for k, v in EXCEPTION_MAPPING.items()}
diff --git a/clients/client-python/gravitino/exceptions/base.py 
b/clients/client-python/gravitino/exceptions/base.py
index b22c59c443..42c0f91168 100644
--- a/clients/client-python/gravitino/exceptions/base.py
+++ b/clients/client-python/gravitino/exceptions/base.py
@@ -157,6 +157,10 @@ class ConnectionFailedException(GravitinoRuntimeException):
     """An exception thrown when connect to catalog failed."""
 
 
+class OptimisticLockException(GravitinoRuntimeException):
+    """Raised when another update changes an entity before a write 
completes."""
+
+
 class UnauthorizedException(GravitinoRuntimeException):
     """An exception thrown when a user is not authorized to perform an 
action."""
 
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/partition_error_handler.py
 
b/clients/client-python/gravitino/exceptions/handlers/partition_error_handler.py
index 52fb36c3e7..0d5b921ea2 100644
--- 
a/clients/client-python/gravitino/exceptions/handlers/partition_error_handler.py
+++ 
b/clients/client-python/gravitino/exceptions/handlers/partition_error_handler.py
@@ -35,13 +35,13 @@ from gravitino.exceptions.handlers.rest_error_handler 
import RestErrorHandler
 class PartitionErrorHandler(RestErrorHandler):
     def handle(self, error_response: ErrorResponse):
         error_message = error_response.format_error_message()
-        code = ErrorConstants(error_response.code())
+        code = error_response.code()
         exception_type = error_response.type()
 
-        if code is ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
+        if code == ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
             raise IllegalArgumentException(error_message)
 
-        if code is ErrorConstants.NOT_FOUND_CODE:
+        if code == ErrorConstants.NOT_FOUND_CODE:
             if exception_type == NoSuchSchemaException.__name__:
                 raise NoSuchSchemaException(error_message)
             if exception_type == NoSuchTableException.__name__:
@@ -50,16 +50,16 @@ class PartitionErrorHandler(RestErrorHandler):
                 raise NoSuchPartitionException(error_message)
             raise NotFoundException(error_message)
 
-        if code is ErrorConstants.ALREADY_EXISTS_CODE:
+        if code == ErrorConstants.ALREADY_EXISTS_CODE:
             raise PartitionAlreadyExistsException(error_message)
 
-        if code is ErrorConstants.INTERNAL_ERROR_CODE:
+        if code == ErrorConstants.INTERNAL_ERROR_CODE:
             raise RuntimeError(error_message)
 
-        if code is ErrorConstants.UNSUPPORTED_OPERATION_CODE:
+        if code == ErrorConstants.UNSUPPORTED_OPERATION_CODE:
             raise UnsupportedOperationException(error_message)
 
-        if code is ErrorConstants.NOT_IN_USE_CODE:
+        if code == ErrorConstants.NOT_IN_USE_CODE:
             if exception_type == CatalogNotInUseException.__name__:
                 raise CatalogNotInUseException(error_message)
             if exception_type == MetalakeNotInUseException.__name__:
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/statistics_error_handler.py
 
b/clients/client-python/gravitino/exceptions/handlers/statistics_error_handler.py
index c2c8a51ab3..c453b66205 100644
--- 
a/clients/client-python/gravitino/exceptions/handlers/statistics_error_handler.py
+++ 
b/clients/client-python/gravitino/exceptions/handlers/statistics_error_handler.py
@@ -39,7 +39,8 @@ class StatisticsErrorHandler(RestErrorHandler):
         self, error_response: ErrorResponse
     ):  # pylint: disable=too-many-branches
         error_message = error_response.format_error_message()
-        code = ErrorConstants(error_response.code())
+        # Keep unknown server codes intact so the common handler can report 
them.
+        code = error_response.code()
         exception_type = error_response.type()
 
         match code:
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/table_error_handler.py 
b/clients/client-python/gravitino/exceptions/handlers/table_error_handler.py
index 85c4846ecf..f3b16cbed9 100644
--- a/clients/client-python/gravitino/exceptions/handlers/table_error_handler.py
+++ b/clients/client-python/gravitino/exceptions/handlers/table_error_handler.py
@@ -35,32 +35,32 @@ from gravitino.exceptions.handlers.rest_error_handler 
import RestErrorHandler
 class TableErrorHandler(RestErrorHandler):
     def handle(self, error_response: ErrorResponse):
         error_message = error_response.format_error_message()
-        code = ErrorConstants(error_response.code())
+        code = error_response.code()
         exception_type = error_response.type()
 
-        if code is ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
+        if code == ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
             raise IllegalArgumentException(error_message)
 
-        if code is ErrorConstants.NOT_FOUND_CODE:
+        if code == ErrorConstants.NOT_FOUND_CODE:
             if exception_type == NoSuchSchemaException.__name__:
                 raise NoSuchSchemaException(error_message)
             if exception_type == NoSuchTableException.__name__:
                 raise NoSuchTableException(error_message)
             raise NotFoundException(error_message)
 
-        if code is ErrorConstants.ALREADY_EXISTS_CODE:
+        if code == ErrorConstants.ALREADY_EXISTS_CODE:
             raise TableAlreadyExistsException(error_message)
 
-        if code is ErrorConstants.INTERNAL_ERROR_CODE:
+        if code == ErrorConstants.INTERNAL_ERROR_CODE:
             raise RuntimeError(error_message)
 
-        if code is ErrorConstants.UNSUPPORTED_OPERATION_CODE:
+        if code == ErrorConstants.UNSUPPORTED_OPERATION_CODE:
             raise UnsupportedOperationException(error_message)
 
-        if code is ErrorConstants.FORBIDDEN_CODE:
+        if code == ErrorConstants.FORBIDDEN_CODE:
             raise ForbiddenException(error_message)
 
-        if code is ErrorConstants.NOT_IN_USE_CODE:
+        if code == ErrorConstants.NOT_IN_USE_CODE:
             if exception_type == CatalogNotInUseException.__name__:
                 raise CatalogNotInUseException(error_message)
             if exception_type == MetalakeNotInUseException.__name__:
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/view_error_handler.py 
b/clients/client-python/gravitino/exceptions/handlers/view_error_handler.py
index 674438c144..a40632bbcf 100644
--- a/clients/client-python/gravitino/exceptions/handlers/view_error_handler.py
+++ b/clients/client-python/gravitino/exceptions/handlers/view_error_handler.py
@@ -38,13 +38,13 @@ class ViewErrorHandler(RestErrorHandler):
 
     def handle(self, error_response: ErrorResponse):
         error_message = error_response.format_error_message()
-        code = ErrorConstants(error_response.code())
+        code = error_response.code()
         exception_type = error_response.type()
 
-        if code is ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
+        if code == ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
             raise IllegalArgumentException(error_message)
 
-        if code is ErrorConstants.NOT_FOUND_CODE:
+        if code == ErrorConstants.NOT_FOUND_CODE:
             if exception_type == NoSuchCatalogException.__name__:
                 raise NoSuchCatalogException(error_message)
             if exception_type == NoSuchSchemaException.__name__:
@@ -53,19 +53,19 @@ class ViewErrorHandler(RestErrorHandler):
                 raise NoSuchViewException(error_message)
             raise NotFoundException(error_message)
 
-        if code is ErrorConstants.ALREADY_EXISTS_CODE:
+        if code == ErrorConstants.ALREADY_EXISTS_CODE:
             raise ViewAlreadyExistsException(error_message)
 
-        if code is ErrorConstants.INTERNAL_ERROR_CODE:
+        if code == ErrorConstants.INTERNAL_ERROR_CODE:
             raise RuntimeError(error_message)
 
-        if code is ErrorConstants.UNSUPPORTED_OPERATION_CODE:
+        if code == ErrorConstants.UNSUPPORTED_OPERATION_CODE:
             raise UnsupportedOperationException(error_message)
 
-        if code is ErrorConstants.FORBIDDEN_CODE:
+        if code == ErrorConstants.FORBIDDEN_CODE:
             raise ForbiddenException(error_message)
 
-        if code is ErrorConstants.NOT_IN_USE_CODE:
+        if code == ErrorConstants.NOT_IN_USE_CODE:
             if exception_type == CatalogNotInUseException.__name__:
                 raise CatalogNotInUseException(error_message)
             if exception_type == MetalakeNotInUseException.__name__:
diff --git a/clients/client-python/tests/unittests/test_error_handler.py 
b/clients/client-python/tests/unittests/test_error_handler.py
index 778581450e..653f0c9708 100644
--- a/clients/client-python/tests/unittests/test_error_handler.py
+++ b/clients/client-python/tests/unittests/test_error_handler.py
@@ -16,6 +16,9 @@
 # under the License.
 
 import unittest
+from io import BytesIO
+from unittest.mock import patch
+from urllib.error import HTTPError
 
 from gravitino.dto.responses.error_response import ErrorResponse
 from gravitino.exceptions.base import (
@@ -28,6 +31,7 @@ from gravitino.exceptions.base import (
     IllegalMetadataObjectException,
     IllegalPrivilegeException,
     IllegalRoleException,
+    IllegalStatisticNameException,
     InternalError,
     MetalakeAlreadyExistsException,
     MetalakeNotInUseException,
@@ -47,11 +51,13 @@ from gravitino.exceptions.base import (
     NotEmptyException,
     NotFoundException,
     NotInUseException,
+    OptimisticLockException,
     PartitionAlreadyExistsException,
     RESTException,
     RoleAlreadyExistsException,
     SchemaAlreadyExistsException,
     TableAlreadyExistsException,
+    UnauthorizedException,
     UnsupportedOperationException,
     UserAlreadyExistsException,
     ViewAlreadyExistsException,
@@ -68,6 +74,9 @@ from gravitino.exceptions.handlers.partition_error_handler 
import (
     PARTITION_ERROR_HANDLER,
 )
 from gravitino.exceptions.handlers.rest_error_handler import REST_ERROR_HANDLER
+from gravitino.exceptions.handlers.statistics_error_handler import (
+    STATISTICS_ERROR_HANDLER,
+)
 from gravitino.exceptions.handlers.permission_error_handler import (
     PERMISSION_ERROR_HANDLER,
 )
@@ -76,9 +85,118 @@ from gravitino.exceptions.handlers.schema_error_handler 
import SCHEMA_ERROR_HAND
 from gravitino.exceptions.handlers.table_error_handler import 
TABLE_ERROR_HANDLER
 from gravitino.exceptions.handlers.user_error_handler import USER_ERROR_HANDLER
 from gravitino.exceptions.handlers.view_error_handler import VIEW_ERROR_HANDLER
+from gravitino.utils.http_client import HTTPClient
 
 
 class TestErrorHandler(unittest.TestCase):
+    def test_http_unauthorized_response(self):
+        body = (
+            b'{"code":1011,"type":"UnauthorizedException",'
+            b'"message":"Authentication failed","stack":null}'
+        )
+        with patch("gravitino.utils.http_client.build_opener") as build_opener:
+            build_opener.return_value.open.side_effect = HTTPError(
+                "http://localhost:8090/api/test";,
+                401,
+                "Unauthorized",
+                None,
+                BytesIO(body),
+            )
+            with self.assertRaisesRegex(UnauthorizedException, "Authentication 
failed"):
+                HTTPClient("http://localhost:8090";).get(
+                    "/api/test", error_handler=TABLE_ERROR_HANDLER
+                )
+
+    def test_unauthorized_error_code_across_handlers(self):
+        response = ErrorResponse.from_json(
+            '{"code":1011,"type":"UnauthorizedException",'
+            '"message":"Authentication failed","stack":null}'
+        )
+        generated = ErrorResponse.generate_error_response(
+            UnauthorizedException, "Authentication failed"
+        )
+        self.assertEqual(1011, generated.code())
+
+        for handler in (
+            REST_ERROR_HANDLER,
+            TABLE_ERROR_HANDLER,
+            VIEW_ERROR_HANDLER,
+            PARTITION_ERROR_HANDLER,
+            STATISTICS_ERROR_HANDLER,
+            CATALOG_ERROR_HANDLER,
+        ):
+            with self.subTest(handler=type(handler).__name__):
+                with self.assertRaisesRegex(
+                    UnauthorizedException, "Authentication failed"
+                ):
+                    handler.handle(response)
+
+    def test_unrecognized_error_code_across_specialized_handlers(self):
+        response = ErrorResponse.from_json(
+            '{"code":1999,"type":"FutureServerException",'
+            '"message":"Future server error","stack":null}'
+        )
+        for handler in (
+            TABLE_ERROR_HANDLER,
+            VIEW_ERROR_HANDLER,
+            PARTITION_ERROR_HANDLER,
+            STATISTICS_ERROR_HANDLER,
+        ):
+            with self.subTest(handler=type(handler).__name__):
+                with self.assertRaisesRegex(RESTException, "Future server 
error"):
+                    handler.handle(response)
+
+    def test_statistics_handler_keeps_specific_errors(self):
+        response = ErrorResponse.from_json(
+            '{"code":1001,"type":"IllegalArgumentException",'
+            '"message":"Invalid statistic","stack":null}'
+        )
+        with self.assertRaisesRegex(IllegalArgumentException, "Invalid 
statistic"):
+            STATISTICS_ERROR_HANDLER.handle(response)
+
+        specific_response = ErrorResponse.from_json(
+            '{"code":1001,"type":"IllegalStatisticNameException",'
+            '"message":"Invalid statistic name","stack":null}'
+        )
+        with self.assertRaisesRegex(
+            IllegalStatisticNameException, "Invalid statistic name"
+        ):
+            STATISTICS_ERROR_HANDLER.handle(specific_response)
+
+    def test_statistics_handler_forwards_unrecognized_codes(self):
+        for code, exception in ((1011, UnauthorizedException), (1999, 
RESTException)):
+            with self.subTest(code=code):
+                response = ErrorResponse.from_json(
+                    f'{{"code":{code},"type":"UnexpectedError",'
+                    '"message":"Server error","stack":null}'
+                )
+                with self.assertRaisesRegex(exception, "Server error"):
+                    STATISTICS_ERROR_HANDLER.handle(response)
+
+    def test_optimistic_lock_conflict(self):
+        response = ErrorResponse.from_json(
+            '{"code":1012,"type":"OptimisticLockException",'
+            '"message":"Concurrent update","stack":null}'
+        )
+        generated = ErrorResponse.generate_error_response(
+            OptimisticLockException, "Concurrent update"
+        )
+        self.assertEqual(1012, generated.code())
+
+        for handler in (
+            REST_ERROR_HANDLER,
+            TABLE_ERROR_HANDLER,
+            VIEW_ERROR_HANDLER,
+            PARTITION_ERROR_HANDLER,
+            STATISTICS_ERROR_HANDLER,
+            CATALOG_ERROR_HANDLER,
+        ):
+            with self.subTest(handler=type(handler).__name__):
+                with self.assertRaisesRegex(
+                    OptimisticLockException, "Concurrent update"
+                ):
+                    handler.handle(response)
+
     def test_rest_error_handler(self):
         with self.assertRaises(RESTException):
             REST_ERROR_HANDLER.handle(
diff --git a/docs/how-to-use-gravitino-client.md 
b/docs/how-to-use-gravitino-client.md
index fdc690afc8..4bcc3bf335 100644
--- a/docs/how-to-use-gravitino-client.md
+++ b/docs/how-to-use-gravitino-client.md
@@ -72,3 +72,23 @@ gravitino_client = GravitinoClient(
 | `gravitino_client_request_timeout` | An optional client timeout in seconds. 
| `10`          | No       |
 
 **Note:** Invalid configuration properties will result in exceptions. 
+
+## Retrying concurrent metadata changes
+
+If another writer changes metadata during an alter or drop, the server returns 
HTTP 409
+with error code `1012`. The Java and Python clients raise 
`OptimisticLockException`.
+Import the exception in Java:
+
+```java
+import org.apache.gravitino.exceptions.OptimisticLockException;
+```
+
+Or in Python:
+
+```python
+from gravitino.exceptions.base import OptimisticLockException
+```
+
+Catch this exception, reload the latest metadata, reconsider your intended 
change, and
+retry with a bounded number of attempts. Do not replay a stale update 
unchanged or
+retry every HTTP 409 response, since other conflicts may require a different 
action.

Reply via email to