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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 9568f39a [core] Support REST catalog branch management (#1141)
9568f39a is described below

commit 9568f39abf1809204f37d7b3f0ec24853b6d8d40
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 17 11:30:14 2026 +0800

    [core] Support REST catalog branch management (#1141)
---
 bindings/python/README.md                          |  20 +
 bindings/python/project-description.md             |  17 +
 .../python/python/pypaimon_rust/datafusion.pyi     |  19 +-
 bindings/python/src/context.rs                     | 138 +++++--
 bindings/python/src/error.rs                       |  90 ++++-
 bindings/python/tests/test_catalog_branches.py     | 221 +++++++++++
 crates/paimon/src/api/api_request.rs               |  12 +
 crates/paimon/src/api/api_response.rs              |   6 +
 crates/paimon/src/api/auth/base.rs                 |  10 +
 crates/paimon/src/api/auth/bearer_provider.rs      |  16 +-
 crates/paimon/src/api/auth/dlf_openapi_actions.rs  | 346 ++++++++++++++++
 .../paimon/src/api/auth/dlf_openapi_v4_signer.rs   | 279 +++++++++++++
 crates/paimon/src/api/auth/dlf_provider.rs         |  16 +-
 crates/paimon/src/api/auth/dlf_signer.rs           |  49 +--
 crates/paimon/src/api/auth/mod.rs                  |   2 +
 crates/paimon/src/api/mod.rs                       |  15 +-
 crates/paimon/src/api/resource_paths.rs            |  27 ++
 crates/paimon/src/api/rest_api.rs                  |  96 ++++-
 crates/paimon/src/api/rest_client.rs               | 182 +++++++--
 crates/paimon/src/api/rest_error.rs                |  47 ++-
 crates/paimon/src/catalog/mod.rs                   |  37 ++
 crates/paimon/src/catalog/rest/rest_catalog.rs     |  84 ++++
 crates/paimon/src/common/options.rs                |   2 +-
 crates/paimon/src/error.rs                         |  10 +
 crates/paimon/tests/rest_branch_test.rs            | 436 +++++++++++++++++++++
 25 files changed, 2057 insertions(+), 120 deletions(-)

diff --git a/bindings/python/README.md b/bindings/python/README.md
index 6244c943..5ce313ee 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -359,6 +359,26 @@ without reading a branch schema file. Omit it to retain 
the original branch.
 Cached time-travel resolution is discarded so the supplied options select the
 snapshot, with the externally resolved fields preserved.
 
+REST catalogs also expose `create_branch(identifier, branch, from_tag=None)`,
+`drop_branch(identifier, branch)`, `fast_forward(identifier, branch)` and
+`list_branches(identifier)`. These delegate to core REST catalog operations and
+release the GIL while waiting for requests. Creation without a tag creates an
+empty branch, as in Java. REST branch rename is unsupported.
+
+REST authentication also supports explicit `dlf.signing-algorithm=openapi-v4`.
+Rust core signs ACS4-HMAC-SHA256 requests with the same canonical request,
+credential scope and action headers as Java. GET requests retry HTTP 429/503 up
+to five times and honor Retry-After; branch mutations are sent once and ignore
+successful response payloads.
+
+`PaimonCatalog(options, config_required=False)` reuses already resolved REST
+options without fetching `/config` again. Branch operation failures raise
+`CatalogError`, a `ValueError` subclass with `code`, `resource_type`,
+`resource_name` and `message` attributes. `code=0` identifies transport or
+response-decoding failures without an HTTP error status. Malformed identifiers
+remain `ValueError`, unsupported catalogs raise `NotImplementedError`, and
+inherited Native state still raises `ForkSafetyError`.
+
 REST tables load the latest snapshot through the catalog, including empty
 results and branch-scoped requests. Permission and service failures (including
 HTTP 501) are propagated as in Java, and the
diff --git a/bindings/python/project-description.md 
b/bindings/python/project-description.md
index 0db99785..44633f86 100644
--- a/bindings/python/project-description.md
+++ b/bindings/python/project-description.md
@@ -148,4 +148,21 @@ ctx = SessionContext()
 ctx.register_catalog_provider("paimon", catalog)
 ```
 
+REST catalogs also support branch management through Rust core:
+
+```python
+identifier = ("db", "table")
+catalog.create_branch(identifier, "empty-branch")
+catalog.create_branch(identifier, "release-branch", from_tag="release-tag")
+branches = catalog.list_branches(identifier)
+release = catalog.get_table(("db", "table$branch_release-branch"))
+catalog.fast_forward(identifier, "release-branch")
+catalog.drop_branch(identifier, "empty-branch")
+```
+
+The server validates branch names and publishes metadata changes. Fast-forward
+updates the identifier's current branch (main by default); it does not change
+the source branch. These catalog methods currently require a REST catalog.
+Other catalog implementations raise `NotImplementedError`.
+
 Time travel queries are not supported in the Python binding at this time.
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 47fa0466..44cee590 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -749,12 +749,29 @@ class StreamWriteBuilder:
     def new_update(self) -> StreamTableUpdate: ...
     def new_commit(self) -> StreamTableCommit: ...
 
+class CatalogError(ValueError):
+    code: int
+    resource_type: Optional[str]
+    resource_name: Optional[str]
+    message: str
+
 class PaimonCatalog:
-    def __init__(self, catalog_options: Dict[str, str]) -> None: ...
+    def __init__(self, catalog_options: Dict[str, str], *, config_required: 
bool = True) -> None:
+        """Set config_required=False when the REST configuration has already 
been resolved."""
+        ...
     def __datafusion_catalog_provider__(self, session: Any) -> object: ...
     def list_databases(self) -> List[str]: ...
     def list_tables(self, database_name: str) -> List[str]: ...
     def get_table(self, identifier: Union[str, Tuple[str, str]]) -> Table: ...
+    def create_branch(self, identifier: Union[str, Tuple[str, str]], 
branch_name: str,
+                      from_tag: Optional[str] = None) -> None:
+        """Create a REST branch, empty unless from_tag is supplied."""
+        ...
+    def drop_branch(self, identifier: Union[str, Tuple[str, str]], 
branch_name: str) -> None: ...
+    def fast_forward(self, identifier: Union[str, Tuple[str, str]], 
branch_name: str) -> None:
+        """Replace the identifier's current branch metadata through the REST 
server."""
+        ...
+    def list_branches(self, identifier: Union[str, Tuple[str, str]]) -> 
List[str]: ...
 
 class PythonScalarUDF:
     def __init__(
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 8177a75b..d7223350 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -41,7 +41,7 @@ use pyo3::prelude::*;
 use pyo3::types::PyCapsule;
 
 use crate::blob::PyBlobReaderRegistry;
-use crate::error::{df_to_py_err, to_py_err};
+use crate::error::{df_to_py_err, to_catalog_py_err, to_py_err};
 use crate::read::PyRecordBatchReader;
 use crate::table::PyTable;
 use crate::udf::{build_python_scalar_udf, udf, PyPythonScalarUDFObject};
@@ -189,12 +189,26 @@ pub(crate) fn complete_storage_options(
     )
 }
 
-fn build_paimon_catalog(catalog_options: HashMap<String, String>) -> 
PyResult<Arc<dyn Catalog>> {
+fn build_paimon_catalog(
+    catalog_options: HashMap<String, String>,
+    config_required: bool,
+) -> PyResult<Arc<dyn Catalog>> {
     let catalog_options = complete_storage_options(catalog_options);
     let rt = runtime();
     rt.block_on(async {
         let options = Options::from_map(catalog_options);
-        let catalog = 
CatalogFactory::create(options).await.map_err(to_py_err)?;
+        let catalog: Arc<dyn Catalog> =
+            if !config_required && 
options.get("metastore").map(String::as_str) == Some("rest") {
+                Arc::new(
+                    paimon::catalog::RESTCatalog::new(options, false)
+                        .await
+                        .map_err(to_catalog_py_err)?,
+                )
+            } else {
+                CatalogFactory::create(options)
+                    .await
+                    .map_err(to_catalog_py_err)?
+            };
         Ok::<_, PyErr>(catalog)
     })
 }
@@ -278,12 +292,47 @@ pub struct PaimonCatalog {
     provider: Arc<PaimonCatalogProvider>,
 }
 
+fn catalog_table_identifier(identifier: &Bound<'_, PyAny>) -> 
PyResult<Identifier> {
+    let (database, object_name) = if let Ok(name) = 
identifier.extract::<String>() {
+        let (database, table) = name.split_once('.').ok_or_else(|| {
+            PyValueError::new_err(format!(
+                "expected identifier in 'db.table' format, got '{name}'"
+            ))
+        })?;
+        (database.to_string(), table.to_string())
+    } else {
+        identifier.extract::<(String, String)>()?
+    };
+    if database.is_empty() || object_name.is_empty() {
+        return Err(PyValueError::new_err(
+            "database and table names must not be empty",
+        ));
+    }
+    let identifier = Identifier::new(&database, &object_name);
+    if identifier
+        .parsed_object_name()
+        .map_err(to_py_err)?
+        .system_table()
+        .is_some()
+    {
+        return Err(PyValueError::new_err(
+            "Catalog table operations do not support system-table identifiers",
+        ));
+    }
+    Ok(identifier)
+}
+
 #[pymethods]
 impl PaimonCatalog {
     /// Create a Paimon catalog that can be registered into a DataFusion 
session.
     #[new]
-    fn new(py: Python<'_>, catalog_options: HashMap<String, String>) -> 
PyResult<Self> {
-        let catalog = py.detach(|| build_paimon_catalog(catalog_options))?;
+    #[pyo3(signature = (catalog_options, *, config_required=true))]
+    fn new(
+        py: Python<'_>,
+        catalog_options: HashMap<String, String>,
+        config_required: bool,
+    ) -> PyResult<Self> {
+        let catalog = py.detach(|| build_paimon_catalog(catalog_options, 
config_required))?;
         let provider = Arc::new(
             PaimonCatalogProvider::new(
                 None,
@@ -334,29 +383,9 @@ impl PaimonCatalog {
     /// Get a table by "db.table" or a (database, table) tuple.
     /// Tuple components preserve dots; the table can include a branch suffix.
     fn get_table(&self, py: Python<'_>, identifier: &Bound<'_, PyAny>) -> 
PyResult<PyTable> {
-        let (database, object_name) = if let Ok(name) = 
identifier.extract::<String>() {
-            let (database, table) = name.split_once('.').ok_or_else(|| {
-                PyValueError::new_err(format!(
-                    "expected identifier in 'db.table' format, got '{name}'"
-                ))
-            })?;
-            (database.to_string(), table.to_string())
-        } else {
-            identifier.extract::<(String, String)>()?
-        };
-        if database.is_empty() || object_name.is_empty() {
-            return Err(PyValueError::new_err(
-                "database and table names must not be empty",
-            ));
-        }
-        let id = Identifier::new(&database, &object_name);
+        let id = catalog_table_identifier(identifier)?;
         let parsed = id.parsed_object_name().map_err(to_py_err)?;
-        if parsed.system_table().is_some() {
-            return Err(PyValueError::new_err(
-                "get_table() does not support system-table identifiers",
-            ));
-        }
-        let base_id = Identifier::new(&database, parsed.table());
+        let base_id = Identifier::new(id.database(), parsed.table());
         let branch = parsed.branch().map(str::to_string);
         let catalog = Arc::clone(&self.catalog);
         let table = py.detach(|| {
@@ -372,6 +401,60 @@ impl PaimonCatalog {
         })?;
         Ok(PyTable::new(Arc::new(table)))
     }
+
+    /// Create an empty branch or one initialized from a tag through the REST 
catalog.
+    #[pyo3(signature = (identifier, branch_name, from_tag=None))]
+    fn create_branch(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+        branch_name: &str,
+        from_tag: Option<&str>,
+    ) -> PyResult<()> {
+        let identifier = catalog_table_identifier(identifier)?;
+        py.detach(|| {
+            runtime().block_on(
+                self.catalog
+                    .create_branch(&identifier, branch_name, from_tag),
+            )
+        })
+        .map_err(to_catalog_py_err)
+    }
+
+    /// Delete branch metadata through the REST catalog.
+    fn drop_branch(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+        branch_name: &str,
+    ) -> PyResult<()> {
+        let identifier = catalog_table_identifier(identifier)?;
+        py.detach(|| runtime().block_on(self.catalog.drop_branch(&identifier, 
branch_name)))
+            .map_err(to_catalog_py_err)
+    }
+
+    /// Fast-forward the identifier's current branch through the REST catalog.
+    fn fast_forward(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+        branch_name: &str,
+    ) -> PyResult<()> {
+        let identifier = catalog_table_identifier(identifier)?;
+        py.detach(|| runtime().block_on(self.catalog.fast_forward(&identifier, 
branch_name)))
+            .map_err(to_catalog_py_err)
+    }
+
+    /// List branch names in REST server order.
+    fn list_branches(
+        &self,
+        py: Python<'_>,
+        identifier: &Bound<'_, PyAny>,
+    ) -> PyResult<Vec<String>> {
+        let identifier = catalog_table_identifier(identifier)?;
+        py.detach(|| 
runtime().block_on(self.catalog.list_branches(&identifier)))
+            .map_err(to_catalog_py_err)
+    }
 }
 
 #[pyclass(name = "SQLOptions", module = "pypaimon_rust.datafusion")]
@@ -794,6 +877,7 @@ impl PySQLContext {
 
 pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
     let this = PyModule::new(py, "datafusion")?;
+    this.add("CatalogError", py.get_type::<crate::error::CatalogError>())?;
     this.add_class::<PaimonCatalog>()?;
     this.add_class::<crate::table::PyTable>()?;
     this.add_class::<crate::system_table::PySystemRead>()?;
diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs
index cb3199db..b94999cb 100644
--- a/bindings/python/src/error.rs
+++ b/bindings/python/src/error.rs
@@ -25,8 +25,96 @@ pyo3::create_exception!(
     "Raised when native state inherited across process fork cannot be used 
safely."
 );
 
+pyo3::create_exception!(
+    pypaimon_rust,
+    CatalogError,
+    PyValueError,
+    "A catalog failure with code, resource_type, resource_name and message 
attributes."
+);
+
 pub fn register_module(py: Python<'_>, module: &Bound<'_, PyModule>) -> 
PyResult<()> {
-    module.add("ForkSafetyError", py.get_type::<ForkSafetyError>())
+    module.add("ForkSafetyError", py.get_type::<ForkSafetyError>())?;
+    module.add("CatalogError", py.get_type::<CatalogError>())
+}
+
+/// Preserve the catalog error contract across the FFI without parsing display 
strings.
+pub(crate) fn to_catalog_py_err(error: paimon::Error) -> PyErr {
+    use paimon::api::RestError;
+    use paimon::Error;
+
+    if error.is_process_fork_unsupported() {
+        return to_py_err(error);
+    }
+    let (code, resource_type, resource_name, message) = match &error {
+        Error::BranchAlreadyExist { branch_name, .. } => (
+            409,
+            Some("BRANCH"),
+            Some(branch_name.as_str()),
+            error.to_string(),
+        ),
+        Error::BranchNotExist { branch_name, .. } => (
+            404,
+            Some("BRANCH"),
+            Some(branch_name.as_str()),
+            error.to_string(),
+        ),
+        Error::TableNotExist { full_name } => (
+            404,
+            Some("TABLE"),
+            Some(full_name.as_str()),
+            error.to_string(),
+        ),
+        Error::TagNotExist { tag_name } => {
+            (404, Some("TAG"), Some(tag_name.as_str()), error.to_string())
+        }
+        Error::DataInvalid { message, .. } => (400, None, None, 
message.clone()),
+        Error::RestApi { source } => {
+            let (code, resource_type, resource_name, message) = match source {
+                RestError::BadRequest { message } => (400, None, None, 
message),
+                RestError::NotAuthorized { message } => (401, None, None, 
message),
+                RestError::Forbidden { message } => (403, None, None, message),
+                RestError::NoSuchResource {
+                    resource_type,
+                    resource_name,
+                    message,
+                } => (
+                    404,
+                    resource_type.as_deref(),
+                    resource_name.as_deref(),
+                    message,
+                ),
+                RestError::AlreadyExists {
+                    resource_type,
+                    resource_name,
+                    message,
+                } => (
+                    409,
+                    resource_type.as_deref(),
+                    resource_name.as_deref(),
+                    message,
+                ),
+                RestError::ServiceFailure { message } => (500, None, None, 
message),
+                RestError::NotImplemented { message } => (501, None, None, 
message),
+                RestError::ServiceUnavailable { message } => (503, None, None, 
message),
+                RestError::Unexpected { code, message } => (*code, None, None, 
message),
+            };
+            (code, resource_type, resource_name, message.clone())
+        }
+        Error::UnexpectedError { .. } | Error::IoUnexpected { .. } => {
+            (0, None, None, error.to_string())
+        }
+        _ => return to_py_err(error),
+    };
+    Python::attach(|py| {
+        let exception = CatalogError::new_err(error.to_string());
+        let value = exception.value(py);
+        value.setattr("code", code)?;
+        value.setattr("resource_type", resource_type)?;
+        value.setattr("resource_name", resource_name)?;
+        value.setattr("message", message)?;
+        Ok::<_, PyErr>(exception)
+    })
+    .unwrap_or_else(|error| error)
 }
 
 pub fn to_py_err(err: paimon::Error) -> PyErr {
diff --git a/bindings/python/tests/test_catalog_branches.py 
b/bindings/python/tests/test_catalog_branches.py
new file mode 100644
index 00000000..a5cef1e9
--- /dev/null
+++ b/bindings/python/tests/test_catalog_branches.py
@@ -0,0 +1,221 @@
+# 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.
+
+"""Branch operations use core REST APIs and release the GIL during requests."""
+
+import json
+import threading
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import unquote_plus
+
+import pytest
+
+from pypaimon_rust.datafusion import CatalogError, PaimonCatalog
+
+
[email protected]
+def rest_server(monkeypatch):
+    requests = []
+    monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1")
+    monkeypatch.setenv("no_proxy", "localhost,127.0.0.1")
+
+    class Handler(BaseHTTPRequestHandler):
+        def handle_operation(self):
+            assert self.headers["Authorization"] == "Bearer test-token"
+            size = int(self.headers.get("Content-Length", "0"))
+            body = json.loads(self.rfile.read(size)) if size else None
+            requests.append((self.command, self.path, body))
+            parts = [unquote_plus(part) for part in self.path.split("/")]
+            if self.path.startswith("/v1/config?"):
+                payload, status = {"defaults": {"prefix": "test"}}, 200
+            elif "missing" in parts:
+                payload, status = {"resourceType": "TABLE", "message": 
"missing", "code": 404}, 404
+            elif "forbidden" in parts:
+                payload, status = {"message": "denied", "code": 403}, 403
+            elif "conflict" in parts:
+                payload, status = {"message": "exists", "code": 409}, 409
+            elif "nocode" in parts:
+                payload, status = {"message": "denied"}, 403
+            elif any(part.startswith("status_") for part in parts):
+                status = int(next(part for part in parts if 
part.startswith("status_")).split("_")[1])
+                payload = {"message": "unmapped status"}
+            elif "invalidjson" in parts:
+                payload, status = "not a branches response", 200
+            elif body and body.get("fromTag") == "missing":
+                payload, status = {"resourceType": "TAG", "message": "tag 
missing", "code": 404}, 404
+            elif self.command == "GET":
+                payload, status = {"branches": ["dev", "release"]}, 200
+            else:
+                payload, status = None, 204
+            output = json.dumps(payload).encode() if status != 204 else b""
+            self.send_response(status)
+            self.send_header("Content-Type", "application/json")
+            self.send_header("Content-Length", str(len(output)))
+            self.end_headers()
+            self.wfile.write(output)
+
+        do_GET = handle_operation
+        do_POST = handle_operation
+        do_DELETE = handle_operation
+
+        def log_message(self, *args):
+            pass
+
+    server = HTTPServer(("localhost", 0), Handler)
+    thread = threading.Thread(target=server.serve_forever)
+    thread.start()
+    try:
+        yield {
+            "metastore": "rest", "uri": "http://localhost:%d"; % 
server.server_port,
+            "warehouse": "warehouse", "token.provider": "bear", "token": 
"test-token",
+        }, requests
+    finally:
+        server.shutdown()
+        server.server_close()
+        thread.join()
+
+
[email protected]
+def catalog(rest_server):
+    options, requests = rest_server
+    native = PaimonCatalog(options)
+    requests.clear()
+    return native, requests
+
+
+def test_resolved_rest_configuration_is_not_fetched_again(rest_server):
+    options, requests = rest_server
+    native = PaimonCatalog(dict(options, prefix="resolved-prefix"), 
config_required=False)
+    assert native.list_branches("db.t") == ["dev", "release"]
+    assert requests == [("GET", 
"/v1/resolved-prefix/databases/db/tables/t/branches", None)]
+
+
+def test_rest_branch_operations_use_core_protocol(catalog):
+    native, requests = catalog
+    identifier = ("db.name", "table.name$branch_dev")
+    native.create_branch(identifier, "empty")
+    native.create_branch(identifier, "tagged", from_tag="tag 一")
+    assert native.list_branches(identifier) == ["dev", "release"]
+    native.fast_forward(identifier, "tagged")
+    native.drop_branch(identifier, "empty")
+    base = "/v1/test/databases/db.name/tables/table.name%24branch_dev/branches"
+    assert requests == [
+        ("POST", base, {"branch": "empty", "fromTag": None}),
+        ("POST", base, {"branch": "tagged", "fromTag": "tag 一"}),
+        ("GET", base, None),
+        ("POST", base + "/tagged/forward", {}),
+        ("DELETE", base + "/empty", None),
+    ]
+
+
+def test_string_identifier_and_branch_path_encoding(catalog):
+    native, requests = catalog
+    native.drop_branch("db.table", "name /?#一")
+    assert requests == [(
+        "DELETE", 
"/v1/test/databases/db/tables/table/branches/name+%2F%3F%23%E4%B8%80", None,
+    )]
+
+
[email protected]("operation", ["drop_branch", "fast_forward"])
[email protected]("name", ["", " ", ".", ".."])
+def test_branch_paths_do_not_mutate_parent_resources(catalog, operation, name):
+    native, requests = catalog
+    with pytest.raises(ValueError):
+        getattr(native, operation)("db.table", name)
+    assert requests == []
+
+
[email protected]("operation", ["create_branch", "drop_branch", 
"fast_forward", "list_branches"])
[email protected]("identifier", ["db", ".table", "db.", ("", "t"), 
("db", ""), "db.table$snapshots"])
+def test_invalid_identifiers_fail_before_sending_requests(catalog, operation, 
identifier):
+    native, requests = catalog
+    args = () if operation == "list_branches" else ("dev",)
+    with pytest.raises(ValueError):
+        getattr(native, operation)(identifier, *args)
+    assert requests == []
+
+
[email protected]("operation", ["create_branch", "drop_branch", 
"fast_forward", "list_branches"])
+def test_rest_branch_errors_are_not_retried(catalog, operation):
+    native, requests = catalog
+    args = () if operation == "list_branches" else ("dev",)
+    with pytest.raises(ValueError, match="does not exist"):
+        getattr(native, operation)("db.missing", *args)
+    assert len(requests) == 1
+    with pytest.raises(ValueError, match="Forbidden"):
+        getattr(native, operation)("db.forbidden", *args)
+    assert len(requests) == 2
+
+
+def test_duplicate_branch_is_reported(catalog):
+    native, requests = catalog
+    with pytest.raises(ValueError, match="Branch dev of table db.conflict 
already exists"):
+        native.create_branch("db.conflict", "dev")
+    assert len(requests) == 1
+
+
[email protected]("operation,identifier,args,code,resource_type,resource_name,message",
 [
+    ("create_branch", "db.conflict", ("dev",), 409, "BRANCH", "dev", "already 
exists"),
+    ("create_branch", "db.missing", ("dev",), 404, "TABLE", "db.missing", 
"does not exist"),
+    ("create_branch", "db.table", ("dev", "missing"), 404, "TAG", "missing", 
"does not exist"),
+    ("drop_branch", "db.missing", ("dev",), 404, "BRANCH", "dev", "does not 
exist"),
+    ("fast_forward", "db.missing", ("dev",), 404, "BRANCH", "dev", "does not 
exist"),
+    ("list_branches", "db.missing", (), 404, "TABLE", "db.missing", "does not 
exist"),
+    ("list_branches", "db.forbidden", (), 403, None, None, "denied"),
+    ("list_branches", "db.nocode", (), 403, None, None, "denied"),
+    ("create_branch", "db.status_429", ("dev",), 429, None, None, "unmapped 
status"),
+    ("list_branches", "db.status_502", (), 502, None, None, "unmapped status"),
+    ("list_branches", "db.status_504", (), 504, None, None, "unmapped status"),
+    ("list_branches", "db.invalidjson", (), 0, None, None, "failed to parse 
json"),
+])
+def test_catalog_errors_preserve_structured_details(
+        catalog, operation, identifier, args, code, resource_type, 
resource_name, message):
+    native, requests = catalog
+    with pytest.raises(CatalogError) as raised:
+        getattr(native, operation)(identifier, *args)
+    error = raised.value
+    assert error.code == code
+    assert error.resource_type == resource_type
+    assert error.resource_name == resource_name
+    assert message in error.message
+    assert isinstance(error, ValueError)
+    assert len(requests) == 1
+
+
+def test_transport_failure_has_structured_catalog_error():
+    import socket
+
+    with socket.socket() as listener:
+        listener.bind(("localhost", 0))
+        native = PaimonCatalog({
+            "metastore": "rest", "uri": "http://localhost:%d"; % 
listener.getsockname()[1],
+            "warehouse": "warehouse", "token.provider": "bear", "token": 
"test-token",
+        }, config_required=False)
+        with pytest.raises(CatalogError) as raised:
+            native.drop_branch("db.t", "dev")
+    assert raised.value.code == 0
+    assert raised.value.resource_type is None
+    assert raised.value.resource_name is None
+    assert "http delete failed" in raised.value.message
+
+
[email protected]("operation", ["create_branch", "drop_branch", 
"fast_forward", "list_branches"])
+def test_catalog_without_branch_management_reports_unsupported(tmp_path, 
operation):
+    native = PaimonCatalog({"warehouse": str(tmp_path)})
+    args = () if operation == "list_branches" else ("dev",)
+    with pytest.raises(NotImplementedError, match="branch management"):
+        getattr(native, operation)("db.table", *args)
diff --git a/crates/paimon/src/api/api_request.rs 
b/crates/paimon/src/api/api_request.rs
index 679ddeb8..b46300c3 100644
--- a/crates/paimon/src/api/api_request.rs
+++ b/crates/paimon/src/api/api_request.rs
@@ -75,6 +75,18 @@ pub struct RenameTableRequest {
     pub destination: Identifier,
 }
 
+/// Request to create a table branch, optionally from a tag.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateBranchRequest {
+    pub branch: String,
+    pub from_tag: Option<String>,
+}
+
+/// Empty JSON object sent by Java's fast-forward operation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ForwardBranchRequest {}
+
 /// Request to create a table tag.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
diff --git a/crates/paimon/src/api/api_response.rs 
b/crates/paimon/src/api/api_response.rs
index 0a595f02..aa489f62 100644
--- a/crates/paimon/src/api/api_response.rs
+++ b/crates/paimon/src/api/api_response.rs
@@ -114,6 +114,12 @@ impl AuditRESTResponse {
     }
 }
 
+/// Branch names returned by a REST catalog, in server order.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ListBranchesResponse {
+    pub branches: Option<Vec<String>>,
+}
+
 /// Latest snapshot and table statistics returned by a REST catalog.
 #[derive(Debug, Clone, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
diff --git a/crates/paimon/src/api/auth/base.rs 
b/crates/paimon/src/api/auth/base.rs
index edf97c28..45fd2a50 100644
--- a/crates/paimon/src/api/auth/base.rs
+++ b/crates/paimon/src/api/auth/base.rs
@@ -99,6 +99,16 @@ pub trait AuthProvider: Send + Sync {
 }
 /// Authorization header key.
 pub const AUTHORIZATION_HEADER_KEY: &str = "Authorization";
+
+/// Authentication headers override caller headers regardless of spelling case.
+pub(super) fn merge_auth_headers(
+    mut base: HashMap<String, String>,
+    generated: HashMap<String, String>,
+) -> HashMap<String, String> {
+    base.retain(|key, _| !generated.keys().any(|name| 
key.eq_ignore_ascii_case(name)));
+    base.extend(generated);
+    base
+}
 /// Function wrapper for REST authentication.
 ///
 /// This struct combines an initial set of headers with an authentication 
provider
diff --git a/crates/paimon/src/api/auth/bearer_provider.rs 
b/crates/paimon/src/api/auth/bearer_provider.rs
index ba6d4dd4..b2c3ddd3 100644
--- a/crates/paimon/src/api/auth/bearer_provider.rs
+++ b/crates/paimon/src/api/auth/bearer_provider.rs
@@ -21,7 +21,7 @@ use std::collections::HashMap;
 
 use async_trait::async_trait;
 
-use super::base::{AuthProvider, RESTAuthParameter};
+use super::base::{merge_auth_headers, AuthProvider, RESTAuthParameter};
 
 /// Authentication provider using Bearer token.
 ///
@@ -47,14 +47,16 @@ impl BearerTokenAuthProvider {
 impl AuthProvider for BearerTokenAuthProvider {
     async fn merge_auth_header(
         &self,
-        mut base_header: HashMap<String, String>,
+        base_header: HashMap<String, String>,
         _parameter: &RESTAuthParameter,
     ) -> crate::Result<HashMap<String, String>> {
-        base_header.insert(
-            "Authorization".to_string(),
-            format!("Bearer {}", self.token),
-        );
-        Ok(base_header)
+        Ok(merge_auth_headers(
+            base_header,
+            HashMap::from([(
+                "Authorization".to_string(),
+                format!("Bearer {}", self.token),
+            )]),
+        ))
     }
 }
 
diff --git a/crates/paimon/src/api/auth/dlf_openapi_actions.rs 
b/crates/paimon/src/api/auth/dlf_openapi_actions.rs
new file mode 100644
index 00000000..a3309d34
--- /dev/null
+++ b/crates/paimon/src/api/auth/dlf_openapi_actions.rs
@@ -0,0 +1,346 @@
+// 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.
+
+//! Java's DlfNext 2026-01-18 REST route to OpenAPI action mapping.
+
+const OPERATIONS: &[(&str, &str, &str)] = &[
+    ("GET", "/v1/config", "GetConfig"),
+    ("GET", "/v1/{prefix}/databases", "ListDatabases"),
+    ("POST", "/v1/{prefix}/databases", "CreateDatabase"),
+    ("GET", "/v1/{prefix}/databases/{database}", "GetDatabase"),
+    ("POST", "/v1/{prefix}/databases/{database}", "AlterDatabase"),
+    (
+        "DELETE",
+        "/v1/{prefix}/databases/{database}",
+        "DropDatabase",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/register",
+        "RegisterTable",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables",
+        "ListTables",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables",
+        "CreateTable",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/table-details",
+        "ListTableDetails",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}",
+        "GetTable",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}",
+        "AlterTable",
+    ),
+    (
+        "DELETE",
+        "/v1/{prefix}/databases/{database}/tables/{table}",
+        "DropTable",
+    ),
+    ("POST", "/v1/{prefix}/tables/rename", "RenameTable"),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/auth",
+        "AuthTableQuery",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/commit",
+        "CommitTable",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}/token",
+        "GetTableToken",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/rollback",
+        "RollbackToSnapshot",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}/snapshot",
+        "GetTableSnapshot",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}/snapshots",
+        "ListSnapshots",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}/snapshots/{version}",
+        "GetVersionSnapshot",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}/partitions",
+        "ListPartitions",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/partitions",
+        "CreatePartitions",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/partitions/drop",
+        "DropPartitions",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/partitions/mark",
+        "MarkDonePartitions",
+    ),
+    (
+        "POST",
+        
"/v1/{prefix}/databases/{database}/tables/{table}/partitions/list-by-names",
+        "ListPartitionsByNames",
+    ),
+    (
+        "POST",
+        
"/v1/{prefix}/databases/{database}/tables/{table}/partitions/list-by-filter",
+        "ListPartitionsByFilter",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}/branches",
+        "ListBranches",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/branches",
+        "CreateBranch",
+    ),
+    (
+        "DELETE",
+        "/v1/{prefix}/databases/{database}/tables/{table}/branches/{branch}",
+        "DropBranch",
+    ),
+    (
+        "POST",
+        
"/v1/{prefix}/databases/{database}/tables/{table}/branches/{branch}/forward",
+        "FastForwardBranch",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}/tags",
+        "ListTags",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/tags",
+        "CreateTag",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}/tags/{tag}",
+        "GetTag",
+    ),
+    (
+        "DELETE",
+        "/v1/{prefix}/databases/{database}/tables/{table}/tags/{tag}",
+        "DropTag",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/tables/{table}/policies",
+        "ListPolicies",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/policies",
+        "CreatePolicy",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/tables/{table}/policies/drop",
+        "DropPolicy",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/views",
+        "ListViews",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/views",
+        "CreateView",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/view-details",
+        "ListViewDetails",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/views/{view}",
+        "GetView",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/views/{view}",
+        "AlterView",
+    ),
+    (
+        "DELETE",
+        "/v1/{prefix}/databases/{database}/views/{view}",
+        "DropView",
+    ),
+    ("POST", "/v1/{prefix}/views/rename", "RenameView"),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/semantic-views",
+        "ListSemanticViews",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/semantic-views/{semanticView}",
+        "GetSemanticView",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/semantic-views/{semanticView}",
+        "UpsertSemanticView",
+    ),
+    (
+        "DELETE",
+        "/v1/{prefix}/databases/{database}/semantic-views/{semanticView}",
+        "DeleteSemanticView",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/functions",
+        "ListFunctions",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/functions",
+        "CreateFunction",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/databases/{database}/functions/{function}",
+        "GetFunction",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/databases/{database}/functions/{function}",
+        "AlterFunction",
+    ),
+    (
+        "DELETE",
+        "/v1/{prefix}/databases/{database}/functions/{function}",
+        "DropFunction",
+    ),
+    (
+        "GET",
+        "/v1/{prefix}/permissions",
+        "ListPermissionAssignments",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/permissions/grant",
+        "GrantPermissionAssignment",
+    ),
+    (
+        "POST",
+        "/v1/{prefix}/permissions/revoke",
+        "RevokePermissionAssignment",
+    ),
+    ("GET", "/v1/{prefix}/labels", "ListLabels"),
+    ("POST", "/v1/{prefix}/labels/get", "GetLabel"),
+    ("POST", "/v1/{prefix}/labels/upsert", "UpsertLabel"),
+    ("POST", "/v1/{prefix}/labels/delete", "DeleteLabel"),
+];
+
+pub(super) fn resolve_action(method: &str, path: &str) -> Option<&'static str> 
{
+    OPERATIONS.iter().find_map(|(verb, template, action)| {
+        (method.eq_ignore_ascii_case(verb)
+            && template.split('/').count() == path.split('/').count()
+            && template
+                .split('/')
+                .zip(path.split('/'))
+                .all(|(part, segment)| {
+                    if part.starts_with('{') {
+                        !segment.is_empty()
+                    } else {
+                        part == segment
+                    }
+                }))
+        .then_some(*action)
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn every_java_operation_resolves_without_collisions() {
+        for (method, template, action) in OPERATIONS {
+            let path = template
+                .split('/')
+                .map(|part| if part.starts_with('{') { "x1" } else { part })
+                .collect::<Vec<_>>()
+                .join("/");
+            assert_eq!(
+                resolve_action(method, &path),
+                Some(*action),
+                "{method} {path}"
+            );
+        }
+    }
+
+    #[test]
+    fn action_resolution_preserves_encoded_names_and_literal_boundaries() {
+        assert_eq!(
+            resolve_action("get", "/v1/rename/databases/tables"),
+            Some("GetDatabase")
+        );
+        assert_eq!(
+            resolve_action("GET", "/v1/p/databases/a%2Fb/tables/c%20d"),
+            Some("GetTable")
+        );
+        assert_eq!(
+            resolve_action("DELETE", 
"/v1/p/databases/db/tables/t/branches/forward"),
+            Some("DropBranch")
+        );
+        for (method, path) in [
+            ("GET", "/v1/p/tables"),
+            ("GET", "/v1/config/"),
+            ("GET", "/v1//databases"),
+            ("PUT", "/v1/p/databases/db/tables/t"),
+            ("POST", "/v1/p/databases/db/tables/t/branches/b/rename"),
+        ] {
+            assert_eq!(resolve_action(method, path), None, "{method} {path}");
+        }
+    }
+}
diff --git a/crates/paimon/src/api/auth/dlf_openapi_v4_signer.rs 
b/crates/paimon/src/api/auth/dlf_openapi_v4_signer.rs
new file mode 100644
index 00000000..98dfdb87
--- /dev/null
+++ b/crates/paimon/src/api/auth/dlf_openapi_v4_signer.rs
@@ -0,0 +1,279 @@
+// 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.
+
+//! Java's Aliyun OpenAPI V4 (ACS4-HMAC-SHA256) request signer.
+
+use std::collections::{BTreeMap, HashMap};
+
+use chrono::{DateTime, Utc};
+use uuid::Uuid;
+
+use super::base::RESTAuthParameter;
+use super::dlf_openapi_actions::resolve_action;
+use super::dlf_provider::DLFToken;
+use super::dlf_signer::{hmac_sha256, sha256_hex, DLFRequestSigner};
+use crate::api::rest_util::RESTUtil;
+
+pub(super) struct DLFOpenApiV4Signer {
+    region: String,
+}
+
+impl DLFOpenApiV4Signer {
+    pub const IDENTIFIER: &'static str = "openapi-v4";
+    const ALGORITHM: &'static str = "ACS4-HMAC-SHA256";
+    const PRODUCT: &'static str = "DlfNext";
+    const REQUEST_TYPE: &'static str = "aliyun_v4_request";
+
+    pub(super) fn new(region: &str) -> Self {
+        Self {
+            region: region.to_string(),
+        }
+    }
+
+    fn percent_encode(value: &str) -> String {
+        RESTUtil::encode_string(value)
+            .replace('+', "%20")
+            .replace('*', "%2A")
+            .replace("%7E", "~")
+    }
+
+    fn canonical_query(parameters: &HashMap<String, String>) -> String {
+        let sorted: BTreeMap<_, _> = parameters.iter().collect();
+        sorted
+            .into_iter()
+            .map(|(key, value)| {
+                // RESTAuthParameter has already encoded each value once, as 
in Java.
+                format!(
+                    "{}={}",
+                    Self::percent_encode(key),
+                    Self::percent_encode(&RESTUtil::decode_string(value))
+                )
+            })
+            .collect::<Vec<_>>()
+            .join("&")
+    }
+
+    fn canonical_headers(headers: &HashMap<String, String>) -> (String, 
String) {
+        let sorted: BTreeMap<_, _> = headers
+            .iter()
+            .filter_map(|(key, value)| {
+                let key = key.to_ascii_lowercase();
+                (key.starts_with("x-acs-") || key == "host" || key == 
"content-type")
+                    .then(|| (key, value.trim()))
+            })
+            .collect();
+        let canonical = sorted
+            .iter()
+            .map(|(key, value)| format!("{key}:{value}\n"))
+            .collect::<String>();
+        let signed = sorted.keys().cloned().collect::<Vec<_>>().join(";");
+        (canonical, signed)
+    }
+}
+
+impl DLFRequestSigner for DLFOpenApiV4Signer {
+    fn sign_headers(
+        &self,
+        body: Option<&str>,
+        now: &DateTime<Utc>,
+        security_token: Option<&str>,
+        host: &str,
+    ) -> HashMap<String, String> {
+        let mut headers = HashMap::from([
+            (
+                "x-acs-date".to_string(),
+                now.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
+            ),
+            ("host".to_string(), host.to_string()),
+            (
+                "x-acs-content-sha256".to_string(),
+                sha256_hex(body.unwrap_or_default()),
+            ),
+            (
+                "x-acs-signature-nonce".to_string(),
+                Uuid::new_v4().to_string(),
+            ),
+            ("x-acs-version".to_string(), "2026-01-18".to_string()),
+        ]);
+        if body.is_some_and(|body| !body.is_empty()) {
+            headers.insert("content-type".to_string(), 
"application/json".to_string());
+        }
+        if let Some(token) = security_token {
+            headers.insert("x-acs-security-token".to_string(), 
token.to_string());
+        }
+        headers
+    }
+
+    fn authorization(
+        &self,
+        parameter: &RESTAuthParameter,
+        token: &DLFToken,
+        _host: &str,
+        headers: &mut HashMap<String, String>,
+    ) -> String {
+        // The action has to be both signed and included in the outgoing 
headers.
+        if let Some(action) = resolve_action(&parameter.method, 
&parameter.path) {
+            headers.insert("x-acs-action".to_string(), action.to_string());
+        }
+        let (canonical_headers, signed_headers) = 
Self::canonical_headers(headers);
+        let uri = if parameter.path.trim().is_empty() {
+            "/"
+        } else {
+            &parameter.path
+        };
+        let empty_hash = sha256_hex("");
+        let payload = 
headers.get("x-acs-content-sha256").unwrap_or(&empty_hash);
+        let canonical = format!(
+            "{}\n{}\n{}\n{}\n{}\n{}",
+            parameter.method,
+            uri,
+            Self::canonical_query(&parameter.parameters),
+            canonical_headers,
+            signed_headers,
+            payload
+        );
+        let to_sign = format!("{}\n{}", Self::ALGORITHM, 
sha256_hex(&canonical));
+        let date = headers["x-acs-date"][..10].replace('-', "");
+        let date_key = hmac_sha256(
+            format!("aliyun_v4{}", token.access_key_secret).as_bytes(),
+            &date,
+        );
+        let region_key = hmac_sha256(&date_key, &self.region);
+        let product_key = hmac_sha256(&region_key, Self::PRODUCT);
+        let signing_key = hmac_sha256(&product_key, Self::REQUEST_TYPE);
+        let signature = hex::encode(hmac_sha256(&signing_key, &to_sign));
+        format!(
+            "{} Credential={}/{}/{}/{}/{},SignedHeaders={},Signature={}",
+            Self::ALGORITHM,
+            token.access_key_id,
+            date,
+            self.region,
+            Self::PRODUCT,
+            Self::REQUEST_TYPE,
+            signed_headers,
+            signature
+        )
+    }
+
+    fn identifier(&self) -> &str {
+        Self::IDENTIFIER
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::super::dlf_signer::DLFSignerFactory;
+    use super::*;
+
+    const HOST: &str = "dlfnext.cn-hangzhou.aliyuncs.com";
+
+    fn now() -> DateTime<Utc> {
+        DateTime::parse_from_rfc3339("2025-04-16T03:44:46Z")
+            .unwrap()
+            .with_timezone(&Utc)
+    }
+
+    #[test]
+    fn java_known_signatures_cover_encoded_queries_and_actions() {
+        let signer = DLFSignerFactory::create_signer("openapi-v4", 
"cn-hangzhou");
+        assert_eq!(signer.identifier(), "openapi-v4");
+        let token = DLFToken::new("TestAKId", "TestAKSecret", None, None, 
None);
+        let parameter = RESTAuthParameter::for_get(
+            "/v1/prefix/functions",
+            HashMap::from([
+                ("pageToken".to_string(), "a b/c".to_string()),
+                ("functionNamePattern".to_string(), "func%".to_string()),
+            ]),
+        );
+        let mut headers = signer.sign_headers(None, &now(), None, HOST);
+        headers.insert(
+            "x-acs-signature-nonce".to_string(),
+            "fixed-nonce-for-test".to_string(),
+        );
+        assert_eq!(signer.authorization(&parameter, &token, HOST, &mut 
headers), concat!(
+            "ACS4-HMAC-SHA256 
Credential=TestAKId/20250416/cn-hangzhou/DlfNext/aliyun_v4_request,",
+            
"SignedHeaders=host;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version,",
+            
"Signature=9812bacd92dd1152e7ea32aacfad0545835d6a18543852364f1a1ea0cba6219a"));
+
+        let body = r#"{"identifier":{"database":"db","object":"t"}}"#;
+        let parameter =
+            
RESTAuthParameter::for_post("/v1/clg-paimon-1/databases/db/tables", 
body.to_string());
+        let mut headers = signer.sign_headers(Some(body), &now(), None, HOST);
+        headers.insert(
+            "x-acs-signature-nonce".to_string(),
+            "fixed-nonce-for-test".to_string(),
+        );
+        assert_eq!(
+            signer.authorization(&parameter, &token, HOST, &mut headers),
+            concat!(
+            "ACS4-HMAC-SHA256 
Credential=TestAKId/20250416/cn-hangzhou/DlfNext/aliyun_v4_request,",
+            
"SignedHeaders=content-type;host;x-acs-action;x-acs-content-sha256;",
+            "x-acs-date;x-acs-signature-nonce;x-acs-version,",
+            
"Signature=420206b5263536e6bc271a7a32582b4a820e23c8c58ae692b708c69f9d19e51d")
+        );
+        assert_eq!(headers["x-acs-action"], "CreateTable");
+    }
+
+    #[test]
+    fn canonical_query_uses_rfc3986_and_decodes_values_once() {
+        let parameter = RESTAuthParameter::for_get(
+            "/",
+            HashMap::from([
+                ("z".to_string(), "".to_string()),
+                ("a b*~".to_string(), "a+b /%一".to_string()),
+            ]),
+        );
+        assert_eq!(
+            DLFOpenApiV4Signer::canonical_query(&parameter.parameters),
+            "a%20b%2A~=a%2Bb%20%2F%25%E4%B8%80&z="
+        );
+    }
+
+    #[test]
+    fn payload_token_and_region_are_covered_by_signature() {
+        let signer = DLFOpenApiV4Signer::new("cn-hangzhou");
+        let token = DLFToken::new("key", "secret", Some("sts".to_string()), 
None, None);
+        let parameter = RESTAuthParameter::for_get("/test", HashMap::new());
+        let mut headers = signer.sign_headers(None, &now(), Some("sts"), HOST);
+        assert_eq!(headers["x-acs-date"], "2025-04-16T03:44:46Z");
+        assert_eq!(
+            headers["x-acs-content-sha256"],
+            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+        );
+        assert!(!headers.contains_key("content-type"));
+        let original = signer.authorization(&parameter, &token, HOST, &mut 
headers);
+        assert!(original.contains(";x-acs-security-token;"));
+        headers.insert("x-acs-security-token".to_string(), 
"changed".to_string());
+        assert_ne!(
+            original,
+            signer.authorization(&parameter, &token, HOST, &mut headers)
+        );
+        headers.insert("x-acs-security-token".to_string(), "sts".to_string());
+        let elsewhere = DLFOpenApiV4Signer::new("cn-beijing");
+        assert_ne!(
+            original,
+            elsewhere.authorization(&parameter, &token, HOST, &mut headers)
+        );
+        let body = signer.sign_headers(Some("一"), &now(), None, HOST);
+        assert_eq!(body["x-acs-content-sha256"], sha256_hex("一"));
+        assert_eq!(body["content-type"], "application/json");
+        assert_ne!(
+            headers["x-acs-signature-nonce"],
+            body["x-acs-signature-nonce"]
+        );
+    }
+}
diff --git a/crates/paimon/src/api/auth/dlf_provider.rs 
b/crates/paimon/src/api/auth/dlf_provider.rs
index 18b0ccc6..486d66da 100644
--- a/crates/paimon/src/api/auth/dlf_provider.rs
+++ b/crates/paimon/src/api/auth/dlf_provider.rs
@@ -25,7 +25,7 @@ use chrono::Utc;
 use reqwest::Client;
 use serde::{Deserialize, Serialize};
 
-use super::base::{AuthProvider, RESTAuthParameter, AUTHORIZATION_HEADER_KEY};
+use super::base::{merge_auth_headers, AuthProvider, RESTAuthParameter, 
AUTHORIZATION_HEADER_KEY};
 use super::dlf_signer::{DLFRequestSigner, DLFSignerFactory};
 use crate::api::user_agent;
 use crate::common::{CatalogOptions, Options};
@@ -334,7 +334,7 @@ const TOKEN_EXPIRATION_SAFE_TIME_MILLIS: i64 = 3_600_000;
 ///
 /// This provider implements authentication for Alibaba Cloud DLF service,
 /// supporting both VPC endpoints (DLF4-HMAC-SHA256) and public endpoints
-/// (ROA v2 HMAC-SHA1).
+/// (ROA v2 HMAC-SHA1 or explicit OpenAPI V4 ACS4-HMAC-SHA256).
 pub struct DLFAuthProvider {
     uri: String,
     token: tokio::sync::Mutex<Option<DLFToken>>,
@@ -426,7 +426,7 @@ impl DLFAuthProvider {
 impl AuthProvider for DLFAuthProvider {
     async fn merge_auth_header(
         &self,
-        mut base_header: HashMap<String, String>,
+        base_header: HashMap<String, String>,
         rest_auth_parameter: &RESTAuthParameter,
     ) -> crate::Result<HashMap<String, String>> {
         // Get token (will auto-refresh if needed via token_loader)
@@ -436,7 +436,7 @@ impl AuthProvider for DLFAuthProvider {
         let host = Self::extract_host(&self.uri);
 
         // Generate signature headers
-        let sign_headers = self.signer.sign_headers(
+        let mut sign_headers = self.signer.sign_headers(
             rest_auth_parameter.data.as_deref(),
             &now,
             token.security_token.as_deref(),
@@ -446,13 +446,11 @@ impl AuthProvider for DLFAuthProvider {
         // Generate authorization header
         let authorization =
             self.signer
-                .authorization(rest_auth_parameter, &token, &host, 
&sign_headers);
+                .authorization(rest_auth_parameter, &token, &host, &mut 
sign_headers);
 
         // Merge all headers
-        base_header.extend(sign_headers);
-        base_header.insert(AUTHORIZATION_HEADER_KEY.to_string(), 
authorization);
-
-        Ok(base_header)
+        sign_headers.insert(AUTHORIZATION_HEADER_KEY.to_string(), 
authorization);
+        Ok(merge_auth_headers(base_header, sign_headers))
     }
 }
 
diff --git a/crates/paimon/src/api/auth/dlf_signer.rs 
b/crates/paimon/src/api/auth/dlf_signer.rs
index 4873b8fd..8fc60256 100644
--- a/crates/paimon/src/api/auth/dlf_signer.rs
+++ b/crates/paimon/src/api/auth/dlf_signer.rs
@@ -17,7 +17,7 @@
 
 //! DLF Request Signer implementations for Alibaba Cloud Data Lake Formation.
 //!
-//! This module provides two signature algorithms for authenticating requests
+//! This module provides signature algorithms for authenticating requests
 //! to Alibaba Cloud Data Lake Formation (DLF) service:
 //!
 //! # Signature Algorithms
@@ -72,6 +72,7 @@
 //! The signer is automatically selected based on the endpoint URI:
 //! - VPC endpoints → `DLFDefaultSigner` (DLF4-HMAC-SHA256)
 //! - Public endpoints with "dlfnext" or "openapi" → `DLFOpenApiSigner` 
(HMAC-SHA1)
+//! - Explicit `openapi-v4` → `DLFOpenApiV4Signer` (ACS4-HMAC-SHA256)
 
 use std::collections::HashMap;
 
@@ -84,12 +85,23 @@ use sha2::{Digest, Sha256};
 use uuid::Uuid;
 
 use super::base::RESTAuthParameter;
+use super::dlf_openapi_v4_signer::DLFOpenApiV4Signer;
 use super::dlf_provider::DLFToken;
 use crate::api::rest_util::RESTUtil;
 
 type HmacSha256 = Hmac<Sha256>;
 type HmacSha1 = Hmac<Sha1>;
 
+pub(super) fn hmac_sha256(key: &[u8], data: &str) -> Vec<u8> {
+    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of 
any size");
+    mac.update(data.as_bytes());
+    mac.finalize().into_bytes().to_vec()
+}
+
+pub(super) fn sha256_hex(raw: &str) -> String {
+    hex::encode(Sha256::digest(raw.as_bytes()))
+}
+
 /// Trait for DLF request signers.
 ///
 /// Different signers implement different signature algorithms for
@@ -99,6 +111,7 @@ type HmacSha1 = Hmac<Sha1>;
 ///
 /// - [`DLFDefaultSigner`]: Uses DLF4-HMAC-SHA256 for VPC endpoints
 /// - [`DLFOpenApiSigner`]: Uses HMAC-SHA1 for public endpoints
+/// - [`DLFOpenApiV4Signer`]: Uses ACS4-HMAC-SHA256 when explicitly configured
 pub trait DLFRequestSigner: Send + Sync {
     /// Generate signature headers for the request.
     fn sign_headers(
@@ -115,7 +128,7 @@ pub trait DLFRequestSigner: Send + Sync {
         rest_auth_parameter: &RESTAuthParameter,
         token: &DLFToken,
         host: &str,
-        sign_headers: &HashMap<String, String>,
+        sign_headers: &mut HashMap<String, String>,
     ) -> String;
     #[allow(dead_code)]
     /// Get the identifier for this signer.
@@ -201,18 +214,6 @@ impl DLFDefaultSigner {
         BASE64_STANDARD.encode(hash)
     }
 
-    fn hmac_sha256(key: &[u8], data: &str) -> Vec<u8> {
-        let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take 
key of any size");
-        mac.update(data.as_bytes());
-        mac.finalize().into_bytes().to_vec()
-    }
-
-    fn sha256_hex(raw: &str) -> String {
-        let mut hasher = Sha256::new();
-        hasher.update(raw.as_bytes());
-        hex::encode(hasher.finalize())
-    }
-
     fn hex_encode(raw: &[u8]) -> String {
         hex::encode(raw)
     }
@@ -340,7 +341,7 @@ impl DLFRequestSigner for DLFDefaultSigner {
         rest_auth_parameter: &RESTAuthParameter,
         token: &DLFToken,
         _host: &str,
-        sign_headers: &HashMap<String, String>,
+        sign_headers: &mut HashMap<String, String>,
     ) -> String {
         let date_time = sign_headers.get(Self::DLF_DATE_HEADER_KEY).unwrap();
         let date = &date_time[..8];
@@ -357,20 +358,20 @@ impl DLFRequestSigner for DLFDefaultSigner {
                 Self::PRODUCT,
                 Self::REQUEST_TYPE
             ),
-            Self::sha256_hex(&canonical_request),
+            sha256_hex(&canonical_request),
         ]
         .join(Self::NEW_LINE);
 
         // Derive signing key
-        let date_key = Self::hmac_sha256(
+        let date_key = hmac_sha256(
             format!("aliyun_v4{}", token.access_key_secret).as_bytes(),
             date,
         );
-        let date_region_key = Self::hmac_sha256(&date_key, &self.region);
-        let date_region_service_key = Self::hmac_sha256(&date_region_key, 
Self::PRODUCT);
-        let signing_key = Self::hmac_sha256(&date_region_service_key, 
Self::REQUEST_TYPE);
+        let date_region_key = hmac_sha256(&date_key, &self.region);
+        let date_region_service_key = hmac_sha256(&date_region_key, 
Self::PRODUCT);
+        let signing_key = hmac_sha256(&date_region_service_key, 
Self::REQUEST_TYPE);
 
-        let signature_bytes = Self::hmac_sha256(&signing_key, &string_to_sign);
+        let signature_bytes = hmac_sha256(&signing_key, &string_to_sign);
         let signature = Self::hex_encode(&signature_bytes);
 
         format!(
@@ -610,7 +611,7 @@ impl DLFRequestSigner for DLFOpenApiSigner {
         rest_auth_parameter: &RESTAuthParameter,
         token: &DLFToken,
         _host: &str,
-        sign_headers: &HashMap<String, String>,
+        sign_headers: &mut HashMap<String, String>,
     ) -> String {
         let canonicalized_headers = 
self.build_canonicalized_headers(sign_headers);
         let canonicalized_resource = 
self.build_canonicalized_resource(rest_auth_parameter);
@@ -642,7 +643,9 @@ pub struct DLFSignerFactory;
 impl DLFSignerFactory {
     /// Create a signer based on the signing algorithm.
     pub fn create_signer(signing_algorithm: &str, region: &str) -> Box<dyn 
DLFRequestSigner> {
-        if signing_algorithm == DLFOpenApiSigner::IDENTIFIER {
+        if signing_algorithm == DLFOpenApiV4Signer::IDENTIFIER {
+            Box::new(DLFOpenApiV4Signer::new(region))
+        } else if signing_algorithm == DLFOpenApiSigner::IDENTIFIER {
             Box::new(DLFOpenApiSigner)
         } else {
             Box::new(DLFDefaultSigner::new(region))
diff --git a/crates/paimon/src/api/auth/mod.rs 
b/crates/paimon/src/api/auth/mod.rs
index 816c3623..23e3ccd6 100644
--- a/crates/paimon/src/api/auth/mod.rs
+++ b/crates/paimon/src/api/auth/mod.rs
@@ -19,6 +19,8 @@
 
 mod base;
 mod bearer_provider;
+mod dlf_openapi_actions;
+mod dlf_openapi_v4_signer;
 mod dlf_provider;
 mod dlf_signer;
 mod factory;
diff --git a/crates/paimon/src/api/mod.rs b/crates/paimon/src/api/mod.rs
index cffb8865..55527ae0 100644
--- a/crates/paimon/src/api/mod.rs
+++ b/crates/paimon/src/api/mod.rs
@@ -33,19 +33,20 @@ mod user_agent;
 
 // Re-export request types
 pub use api_request::{
-    AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest, 
CreateDatabaseRequest,
-    CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest, 
CreateTagRequest,
-    CreateViewRequest, DropPartitionsRequest, DropPolicyRequest, 
ListPartitionsByFilterRequest,
-    ListPartitionsByNamesRequest, PolicyRequest, RenameTableRequest, 
RevokePermissionRequest,
+    AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest, 
CreateBranchRequest,
+    CreateDatabaseRequest, CreateFunctionRequest, CreatePartitionsRequest, 
CreateTableRequest,
+    CreateTagRequest, CreateViewRequest, DropPartitionsRequest, 
DropPolicyRequest,
+    ForwardBranchRequest, ListPartitionsByFilterRequest, 
ListPartitionsByNamesRequest,
+    PolicyRequest, RenameTableRequest, RevokePermissionRequest,
 };
 
 // Re-export response types
 pub use api_response::{
     AuditRESTResponse, AuthTableQueryResponse, ConfigResponse, ErrorResponse, 
GetDatabaseResponse,
     GetFunctionResponse, GetTableResponse, GetTableSnapshotResponse, 
GetTableTokenResponse,
-    GetTagResponse, GetViewResponse, ListDatabasesResponse, 
ListFunctionsResponse,
-    ListPartitionsResponse, ListPermissionsResponse, ListPoliciesResponse, 
ListTablesResponse,
-    ListViewsResponse, PagedList, TableSnapshot,
+    GetTagResponse, GetViewResponse, ListBranchesResponse, 
ListDatabasesResponse,
+    ListFunctionsResponse, ListPartitionsResponse, ListPermissionsResponse, 
ListPoliciesResponse,
+    ListTablesResponse, ListViewsResponse, PagedList, TableSnapshot,
 };
 
 // Re-export management types
diff --git a/crates/paimon/src/api/resource_paths.rs 
b/crates/paimon/src/api/resource_paths.rs
index 62f1bcf4..14e97322 100644
--- a/crates/paimon/src/api/resource_paths.rs
+++ b/crates/paimon/src/api/resource_paths.rs
@@ -121,6 +121,33 @@ impl ResourcePaths {
         format!("{}/snapshot", self.table(database_name, table_name))
     }
 
+    /// Get the branch collection for a table.
+    pub fn branches(&self, database_name: &str, table_name: &str) -> String {
+        format!("{}/branches", self.table(database_name, table_name))
+    }
+
+    /// Get one branch, encoding its name as a single path component.
+    pub fn branch(&self, database_name: &str, table_name: &str, branch_name: 
&str) -> String {
+        format!(
+            "{}/{}",
+            self.branches(database_name, table_name),
+            RESTUtil::encode_string(branch_name)
+        )
+    }
+
+    /// Get the fast-forward endpoint for a branch.
+    pub fn forward_branch(
+        &self,
+        database_name: &str,
+        table_name: &str,
+        branch_name: &str,
+    ) -> String {
+        format!(
+            "{}/forward",
+            self.branch(database_name, table_name, branch_name)
+        )
+    }
+
     /// Get the views endpoint path for a database.
     pub fn views(&self, database_name: &str) -> String {
         format!(
diff --git a/crates/paimon/src/api/rest_api.rs 
b/crates/paimon/src/api/rest_api.rs
index 57828e34..45ec5a86 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -29,16 +29,17 @@ use crate::spec::{Partition, PartitionStatistics, Schema, 
SchemaChange, Snapshot
 use crate::Result;
 
 use super::api_request::{
-    AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest, 
CreateDatabaseRequest,
-    CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest, 
CreateTagRequest,
-    CreateViewRequest, DropPartitionsRequest, DropPolicyRequest, 
ListPartitionsByFilterRequest,
-    ListPartitionsByNamesRequest, PolicyRequest, RenameTableRequest, 
RevokePermissionRequest,
+    AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest, 
CreateBranchRequest,
+    CreateDatabaseRequest, CreateFunctionRequest, CreatePartitionsRequest, 
CreateTableRequest,
+    CreateTagRequest, CreateViewRequest, DropPartitionsRequest, 
DropPolicyRequest,
+    ForwardBranchRequest, ListPartitionsByFilterRequest, 
ListPartitionsByNamesRequest,
+    PolicyRequest, RenameTableRequest, RevokePermissionRequest,
 };
 use super::api_response::{
     AuthTableQueryResponse, ConfigResponse, ErrorResponse, GetDatabaseResponse,
-    GetFunctionResponse, GetTableResponse, GetTagResponse, GetViewResponse, 
ListDatabasesResponse,
-    ListFunctionsResponse, ListPartitionsResponse, ListPermissionsResponse, 
ListPoliciesResponse,
-    ListTablesResponse, ListViewsResponse, PagedList,
+    GetFunctionResponse, GetTableResponse, GetTagResponse, GetViewResponse, 
ListBranchesResponse,
+    ListDatabasesResponse, ListFunctionsResponse, ListPartitionsResponse, 
ListPermissionsResponse,
+    ListPoliciesResponse, ListTablesResponse, ListViewsResponse, PagedList,
 };
 use super::auth::{AuthProviderFactory, RESTAuthFunction};
 use super::management::{
@@ -80,6 +81,20 @@ fn validate_non_empty_multi(values: &[(&str, &str)]) -> 
Result<()> {
     Ok(())
 }
 
+/// `url` normalizes dot segments even when their dots are percent encoded.
+/// Reject them before a branch mutation can be redirected to a parent 
resource.
+fn validate_resource_path(values: &[(&str, &str)]) -> Result<()> {
+    validate_non_empty_multi(values)?;
+    for (value, field_name) in values {
+        if matches!(*value, "." | "..") {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!("{field_name} must not be a dot path 
segment"),
+            });
+        }
+    }
+    Ok(())
+}
+
 /// The canonical `(database, table)` of a policy resource, shared by all 
three calls.
 fn policy_table(resource: PermissionResource) -> Result<(String, String)> {
     resource.validate_policy_attachment()?;
@@ -523,6 +538,73 @@ impl RESTApi {
         Ok(())
     }
 
+    // ==================== Branch Operations ====================
+
+    /// Create an empty branch or create one from a tag, as in Java `RESTApi`.
+    pub async fn create_branch(
+        &self,
+        identifier: &Identifier,
+        branch_name: &str,
+        from_tag: Option<&str>,
+    ) -> Result<()> {
+        validate_resource_path(&[
+            (identifier.database(), "database name"),
+            (identifier.object(), "table name"),
+        ])?;
+        let path = self
+            .resource_paths
+            .branches(identifier.database(), identifier.object());
+        let request = CreateBranchRequest {
+            branch: branch_name.to_string(),
+            from_tag: from_tag.map(str::to_string),
+        };
+        self.client.post_without_response(&path, &request).await
+    }
+
+    /// Drop branch metadata through the catalog's publication protocol.
+    pub async fn drop_branch(&self, identifier: &Identifier, branch_name: 
&str) -> Result<()> {
+        validate_resource_path(&[
+            (identifier.database(), "database name"),
+            (identifier.object(), "table name"),
+            (branch_name, "branch name"),
+        ])?;
+        let path =
+            self.resource_paths
+                .branch(identifier.database(), identifier.object(), 
branch_name);
+        self.client.delete_without_response(&path).await
+    }
+
+    /// Ask the server to fast-forward from a branch. No files are changed 
locally.
+    pub async fn fast_forward(&self, identifier: &Identifier, branch_name: 
&str) -> Result<()> {
+        validate_resource_path(&[
+            (identifier.database(), "database name"),
+            (identifier.object(), "table name"),
+            (branch_name, "branch name"),
+        ])?;
+        let path = self.resource_paths.forward_branch(
+            identifier.database(),
+            identifier.object(),
+            branch_name,
+        );
+        self.client
+            .post_without_response(&path, &ForwardBranchRequest {})
+            .await
+    }
+
+    /// List the server's branches, preserving its order and null-list 
behavior.
+    pub async fn list_branches(&self, identifier: &Identifier) -> 
Result<Vec<String>> {
+        validate_resource_path(&[
+            (identifier.database(), "database name"),
+            (identifier.object(), "table name"),
+        ])?;
+        let path = self
+            .resource_paths
+            .branches(identifier.database(), identifier.object());
+        let response: ListBranchesResponse =
+            self.client.get(&path, None::<&[(&str, &str)]>).await?;
+        Ok(response.branches.unwrap_or_default())
+    }
+
     // ==================== Function Operations ====================
 
     /// Create a persistent function.
diff --git a/crates/paimon/src/api/rest_client.rs 
b/crates/paimon/src/api/rest_client.rs
index 5448d47c..22f320c0 100644
--- a/crates/paimon/src/api/rest_client.rs
+++ b/crates/paimon/src/api/rest_client.rs
@@ -22,7 +22,7 @@ use super::rest_error::RestError;
 use super::user_agent;
 use crate::Error;
 use crate::Result;
-use reqwest::header::HeaderValue;
+use reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER};
 use serde::de::DeserializeOwned;
 use std::collections::HashMap;
 use std::time::Duration;
@@ -77,7 +77,10 @@ impl HttpClient {
                 .expect("the default User-Agent is visible ASCII")
         });
         reqwest::Client::builder()
-            .timeout(Duration::from_secs(30))
+            // Java and PyPaimon allow three minutes for connection and 
response
+            // reads. A short total deadline aborts otherwise valid REST calls.
+            .connect_timeout(Duration::from_secs(180))
+            .read_timeout(Duration::from_secs(180))
             .user_agent(agent)
             .build()
             .map_err(|e| Error::ConfigInvalid {
@@ -146,12 +149,61 @@ impl HttpClient {
             }
         }
 
-        let request = Self::apply_headers(request, &headers);
-        let resp = request.send().await.map_err(|e| Error::UnexpectedError {
-            message: "http get failed".to_string(),
-            source: Some(Box::new(e)),
-        })?;
-        self.parse_response(resp).await
+        let request = Self::apply_headers(request, &headers)?
+            .build()
+            .map_err(|e| Error::UnexpectedError {
+                message: "http get failed".to_string(),
+                source: Some(Box::new(e)),
+            })?;
+        // Retry only idempotent reads and only on Java's retryable HTTP 
statuses.
+        // A mutation may have been published before an intermediary returned 
an
+        // error, so POST/DELETE must never enter this loop.
+        for attempt in 0..=5 {
+            let pending = request
+                .try_clone()
+                .expect("GET requests have no streaming body");
+            let response =
+                self.client
+                    .execute(pending)
+                    .await
+                    .map_err(|e| Error::UnexpectedError {
+                        message: "http get failed".to_string(),
+                        source: Some(Box::new(e)),
+                    })?;
+            if attempt == 5 || !matches!(response.status().as_u16(), 429 | 
503) {
+                return self.parse_response(response).await;
+            }
+            let retry_after = response
+                .headers()
+                .get(RETRY_AFTER)
+                .and_then(|value| value.to_str().ok());
+            let delay = Self::retry_delay(retry_after, attempt, 
chrono::Utc::now());
+            drop(response);
+            tokio::time::sleep(delay).await;
+        }
+        unreachable!("the final attempt returns its response")
+    }
+
+    fn retry_delay(
+        retry_after: Option<&str>,
+        attempt: u32,
+        now: chrono::DateTime<chrono::Utc>,
+    ) -> Duration {
+        if let Some(value) = retry_after {
+            let delay = value
+                .parse::<u64>()
+                .ok()
+                .map(Duration::from_secs)
+                .or_else(|| {
+                    chrono::DateTime::parse_from_rfc2822(value)
+                        .ok()
+                        .and_then(|date| 
date.signed_duration_since(now).to_std().ok())
+                });
+            if let Some(delay) = delay.filter(|delay| !delay.is_zero()) {
+                return delay;
+            }
+        }
+        Duration::from_secs(1 << attempt.min(6))
     }
 
     /// Perform a POST request with a JSON body.
@@ -167,18 +219,35 @@ impl HttpClient {
         path: &str,
         body: &B,
     ) -> Result<T> {
+        self.parse_response(self.send_post(path, body).await?).await
+    }
+
+    /// Send a mutation whose successful response has no defined payload.
+    pub(crate) async fn post_without_response<B: serde::Serialize>(
+        &self,
+        path: &str,
+        body: &B,
+    ) -> Result<()> {
+        Self::response_text(self.send_post(path, body).await?).await?;
+        Ok(())
+    }
+
+    async fn send_post<B: serde::Serialize>(
+        &self,
+        path: &str,
+        body: &B,
+    ) -> Result<reqwest::Response> {
         let url = self.request_url(path);
         let body_str = serde_json::to_string(body).ok();
         let headers = self
             .build_auth_headers("POST", path, body_str.as_deref(), 
HashMap::new())
             .await?;
         let request = self.client.post(&url).json(body);
-        let request = Self::apply_headers(request, &headers);
-        let resp = request.send().await.map_err(|e| Error::UnexpectedError {
+        let request = Self::apply_headers(request, &headers)?;
+        request.send().await.map_err(|e| Error::UnexpectedError {
             message: "http post failed".to_string(),
             source: Some(Box::new(e)),
-        })?;
-        self.parse_response(resp).await
+        })
     }
 
     /// Perform a DELETE request with optional query parameters.
@@ -194,6 +263,21 @@ impl HttpClient {
         path: &str,
         params: Option<&[(impl AsRef<str>, impl AsRef<str>)]>,
     ) -> Result<T> {
+        self.parse_response(self.send_delete(path, params).await?)
+            .await
+    }
+
+    /// Delete metadata, ignoring the successful payload as Java does.
+    pub(crate) async fn delete_without_response(&self, path: &str) -> 
Result<()> {
+        Self::response_text(self.send_delete(path, None::<&[(&str, 
&str)]>).await?).await?;
+        Ok(())
+    }
+
+    async fn send_delete(
+        &self,
+        path: &str,
+        params: Option<&[(impl AsRef<str>, impl AsRef<str>)]>,
+    ) -> Result<reqwest::Response> {
         let url = self.request_url(path);
 
         let params_map: HashMap<String, String> = match params {
@@ -215,12 +299,11 @@ impl HttpClient {
             }
         }
 
-        let request = Self::apply_headers(request, &headers);
-        let resp = request.send().await.map_err(|e| Error::UnexpectedError {
+        let request = Self::apply_headers(request, &headers)?;
+        request.send().await.map_err(|e| Error::UnexpectedError {
             message: "http delete failed".to_string(),
             source: Some(Box::new(e)),
-        })?;
-        self.parse_response(resp).await
+        })
     }
 
     /// Set the authentication function for this client.
@@ -249,12 +332,13 @@ impl HttpClient {
     fn apply_headers(
         request: reqwest::RequestBuilder,
         headers: &HashMap<String, String>,
-    ) -> reqwest::RequestBuilder {
-        let mut request = request;
-        for (key, value) in headers {
-            request = request.header(key, value);
-        }
-        request
+    ) -> Result<reqwest::RequestBuilder> {
+        let headers = HeaderMap::try_from(headers).map_err(|error| 
Error::UnexpectedError {
+            message: "Invalid REST request headers".to_string(),
+            source: Some(Box::new(error)),
+        })?;
+        // Replace JSON's default Content-Type rather than append a second 
value.
+        Ok(request.headers(headers))
     }
 
     fn request_url(&self, path: &str) -> String {
@@ -267,27 +351,24 @@ impl HttpClient {
         }
     }
 
-    async fn parse_response<T: DeserializeOwned>(&self, resp: 
reqwest::Response) -> Result<T> {
+    async fn response_text(resp: reqwest::Response) -> Result<String> {
         let status = resp.status();
-
+        let text = resp.text().await.map_err(|e| Error::UnexpectedError {
+            message: "failed to read response".to_string(),
+            source: Some(Box::new(e)),
+        })?;
         if !status.is_success() {
-            let text = resp.text().await.map_err(|e| Error::UnexpectedError {
-                message: "failed to read response".to_string(),
-                source: Some(Box::new(e)),
-            })?;
-
             // Parse error response as ErrorResponse and map code to 
corresponding error
             let error_response: super::ErrorResponse =
                 RestError::parse_error_response(&text, status.as_u16());
             let rest_error: RestError = 
RestError::from_error_response(error_response);
             return Err(Error::from(rest_error));
         }
+        Ok(text)
+    }
 
-        // Parse successful response
-        let text = resp.text().await.map_err(|e| Error::UnexpectedError {
-            message: "failed to read response".to_string(),
-            source: Some(Box::new(e)),
-        })?;
+    async fn parse_response<T: DeserializeOwned>(&self, resp: 
reqwest::Response) -> Result<T> {
+        let text = Self::response_text(resp).await?;
 
         // Handle empty response body - return null as default for types like 
serde_json::Value
         if text.trim().is_empty() {
@@ -317,6 +398,39 @@ mod tests {
     use super::*;
     use crate::api::auth::AuthProvider;
 
+    #[test]
+    fn retry_after_or_exponential_backoff_matches_java() {
+        let now = chrono::DateTime::parse_from_rfc3339("2025-04-16T03:44:46Z")
+            .unwrap()
+            .with_timezone(&chrono::Utc);
+        for (header, attempt, seconds) in [
+            (Some("5"), 0, 5),
+            (Some("Wed, 16 Apr 2025 03:44:49 GMT"), 0, 3),
+            (Some("Wed, 16 Apr 2025 03:44:45 GMT"), 2, 4),
+            (None, 0, 1),
+            (Some("invalid"), 3, 8),
+            (Some("0"), 1, 2),
+        ] {
+            assert_eq!(
+                HttpClient::retry_delay(header, attempt, now),
+                Duration::from_secs(seconds)
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn invalid_get_headers_fail_before_sending() {
+        let auth = RESTAuthFunction::new(
+            HashMap::from([("X-Invalid".to_string(), 
"line\nbreak".to_string())]),
+            Box::new(RecordingProvider(Arc::new(Mutex::new(None)))),
+        );
+        let client = HttpClient::new("http://localhost:0";, 
Some(auth)).unwrap();
+        assert!(
+            matches!(client.get::<serde_json::Value>("/", None::<&[(&str, 
&str)]>).await,
+            Err(Error::UnexpectedError { message, .. }) if message == "Invalid 
REST request headers")
+        );
+    }
+
     struct RecordingProvider(Arc<Mutex<Option<HashMap<String, String>>>>);
 
     #[async_trait]
diff --git a/crates/paimon/src/api/rest_error.rs 
b/crates/paimon/src/api/rest_error.rs
index 8b4477dd..0ea3af75 100644
--- a/crates/paimon/src/api/rest_error.rs
+++ b/crates/paimon/src/api/rest_error.rs
@@ -77,7 +77,7 @@ pub enum RestError {
 
     /// Unexpected error
     #[snafu(display("Unexpected error: {}", message))]
-    Unexpected { message: String },
+    Unexpected { code: i32, message: String },
 }
 
 use super::api_response::ErrorResponse;
@@ -89,7 +89,8 @@ impl RestError {
     /// creates an ErrorResponse with the raw response body as the message and 
the
     /// HTTP status code as the code.
     pub fn parse_error_response(text: &str, status_code: u16) -> ErrorResponse 
{
-        if let Ok(error) = serde_json::from_str::<ErrorResponse>(text) {
+        if let Ok(mut error) = serde_json::from_str::<ErrorResponse>(text) {
+            error.code = error.code.or(Some(status_code as i32));
             if error.message.is_some() {
                 return error;
             }
@@ -140,7 +141,47 @@ impl RestError {
             500 => RestError::ServiceFailure { message },
             501 => RestError::NotImplemented { message },
             503 => RestError::ServiceUnavailable { message },
-            _ => RestError::Unexpected { message },
+            _ => RestError::Unexpected { code, message },
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn missing_json_code_uses_http_status() {
+        for code in [400, 401, 403, 404, 409, 500, 501, 503] {
+            let response = RestError::parse_error_response(
+                
r#"{"resourceType":"TABLE","resourceName":"t","message":"denied"}"#,
+                code,
+            );
+            assert_eq!(response.code, Some(i32::from(code)));
+            assert_eq!(response.message.as_deref(), Some("denied"));
+            assert_eq!(response.resource_type.as_deref(), Some("TABLE"));
+            assert_eq!(response.resource_name.as_deref(), Some("t"));
+        }
+    }
+
+    #[test]
+    fn explicit_json_code_takes_precedence() {
+        let response = 
RestError::parse_error_response(r#"{"message":"denied","code":403}"#, 500);
+        assert!(matches!(
+            RestError::from_error_response(response),
+            RestError::Forbidden { message } if message == "denied"
+        ));
+    }
+
+    #[test]
+    fn unknown_status_preserves_code_and_raw_message() {
+        for code in [429, 502, 504] {
+            let response = RestError::parse_error_response("gateway 
unavailable", code);
+            assert!(matches!(
+                RestError::from_error_response(response),
+                RestError::Unexpected { code: actual, message }
+                    if actual == i32::from(code) && message == "gateway 
unavailable"
+            ));
         }
     }
 }
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 5c48f454..12e73a84 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -570,6 +570,43 @@ pub trait Catalog: Send + Sync {
         })
     }
 
+    // ======================= branch methods =============================
+
+    /// Create an empty branch, or create one from a tag when `from_tag` is 
set.
+    /// Branch names and table capabilities are validated by the catalog.
+    async fn create_branch(
+        &self,
+        _identifier: &Identifier,
+        _branch_name: &str,
+        _from_tag: Option<&str>,
+    ) -> Result<()> {
+        Err(Error::Unsupported {
+            message: "branch management is not supported by this 
catalog".to_string(),
+        })
+    }
+
+    /// Delete a branch's metadata. Shared data files must remain intact.
+    async fn drop_branch(&self, _identifier: &Identifier, _branch_name: &str) 
-> Result<()> {
+        Err(Error::Unsupported {
+            message: "branch management is not supported by this 
catalog".to_string(),
+        })
+    }
+
+    /// Replace the identifier's current branch metadata with that of 
`branch_name`.
+    /// The REST catalog delegates the operation and its validation to the 
server.
+    async fn fast_forward(&self, _identifier: &Identifier, _branch_name: &str) 
-> Result<()> {
+        Err(Error::Unsupported {
+            message: "branch management is not supported by this 
catalog".to_string(),
+        })
+    }
+
+    /// List branches in the order supplied by the catalog, excluding no names 
locally.
+    async fn list_branches(&self, _identifier: &Identifier) -> 
Result<Vec<String>> {
+        Err(Error::Unsupported {
+            message: "branch management is not supported by this 
catalog".to_string(),
+        })
+    }
+
     // ======================= view methods ===============================
 
     /// Create a persistent view.
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 1097f799..3f4f623d 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -487,6 +487,78 @@ impl Catalog for RESTCatalog {
         })
     }
 
+    async fn create_branch(
+        &self,
+        identifier: &Identifier,
+        branch_name: &str,
+        from_tag: Option<&str>,
+    ) -> Result<()> {
+        self.api
+            .create_branch(identifier, branch_name, from_tag)
+            .await
+            .map_err(|error| match error {
+                Error::RestApi {
+                    source:
+                        RestError::NoSuchResource {
+                            resource_type: Some(kind),
+                            ..
+                        },
+                } if kind == "TABLE" => Error::TableNotExist {
+                    full_name: identifier.full_name(),
+                },
+                Error::RestApi {
+                    source:
+                        RestError::NoSuchResource {
+                            resource_type: Some(kind),
+                            ..
+                        },
+                } if kind == "TAG" => Error::TagNotExist {
+                    tag_name: from_tag.unwrap_or_default().to_string(),
+                },
+                Error::RestApi {
+                    source: RestError::AlreadyExists { .. },
+                } => Error::BranchAlreadyExist {
+                    full_name: identifier.full_name(),
+                    branch_name: branch_name.to_string(),
+                },
+                Error::RestApi {
+                    source: RestError::BadRequest { message },
+                } => Error::DataInvalid {
+                    message,
+                    source: None,
+                },
+                other => other,
+            })
+    }
+
+    async fn drop_branch(&self, identifier: &Identifier, branch_name: &str) -> 
Result<()> {
+        self.api
+            .drop_branch(identifier, branch_name)
+            .await
+            .map_err(|error| map_rest_error_for_branch(error, identifier, 
branch_name))
+    }
+
+    async fn fast_forward(&self, identifier: &Identifier, branch_name: &str) 
-> Result<()> {
+        self.api
+            .fast_forward(identifier, branch_name)
+            .await
+            .map_err(|error| map_rest_error_for_branch(error, identifier, 
branch_name))
+    }
+
+    async fn list_branches(&self, identifier: &Identifier) -> 
Result<Vec<String>> {
+        self.api
+            .list_branches(identifier)
+            .await
+            .map_err(|error| match error {
+                Error::RestApi {
+                    source: RestError::NoSuchResource { .. },
+                } => Error::TableNotExist {
+                    full_name: identifier.full_name(),
+                },
+                other => other,
+            })
+    }
+
     async fn list_views(&self, database_name: &str) -> Result<Vec<String>> {
         self.api
             .list_views(database_name)
@@ -711,6 +783,18 @@ impl Catalog for RESTCatalog {
 // Error mapping helpers
 // ============================================================================
 
+fn map_rest_error_for_branch(error: Error, identifier: &Identifier, 
branch_name: &str) -> Error {
+    match error {
+        Error::RestApi {
+            source: RestError::NoSuchResource { .. },
+        } => Error::BranchNotExist {
+            full_name: identifier.full_name(),
+            branch_name: branch_name.to_string(),
+        },
+        other => other,
+    }
+}
+
 /// Map a REST API error to a catalog-level database error.
 ///
 /// Converts `RestError::NoSuchResource` -> `Error::DatabaseNotExist`,
diff --git a/crates/paimon/src/common/options.rs 
b/crates/paimon/src/common/options.rs
index 3eed389e..669b6938 100644
--- a/crates/paimon/src/common/options.rs
+++ b/crates/paimon/src/common/options.rs
@@ -58,7 +58,7 @@ impl CatalogOptions {
     /// DLF security token (optional, for temporary credentials).
     pub const DLF_ACCESS_SECURITY_TOKEN: &'static str = "dlf.security-token";
 
-    /// DLF signing algorithm (default or openapi).
+    /// DLF signing algorithm (default, openapi or openapi-v4).
     pub const DLF_SIGNING_ALGORITHM: &'static str = "dlf.signing-algorithm";
 
     /// DLF token loader type ("ecs" or "local_file").
diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index 0087f16d..01f877aa 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -118,6 +118,16 @@ pub enum Error {
     TagAlreadyExist { tag_name: String },
     #[snafu(display("Tag {} does not exist.", tag_name))]
     TagNotExist { tag_name: String },
+    #[snafu(display("Branch {} of table {} already exists.", branch_name, 
full_name))]
+    BranchAlreadyExist {
+        full_name: String,
+        branch_name: String,
+    },
+    #[snafu(display("Branch {} of table {} does not exist.", branch_name, 
full_name))]
+    BranchNotExist {
+        full_name: String,
+        branch_name: String,
+    },
     #[snafu(display("View {} already exists.", full_name))]
     ViewAlreadyExist { full_name: String },
     #[snafu(display("View {} does not exist.", full_name))]
diff --git a/crates/paimon/tests/rest_branch_test.rs 
b/crates/paimon/tests/rest_branch_test.rs
new file mode 100644
index 00000000..1b5a4f00
--- /dev/null
+++ b/crates/paimon/tests/rest_branch_test.rs
@@ -0,0 +1,436 @@
+// 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.
+
+//! Java REST branch protocol, exercised through the public Catalog contract.
+
+use std::collections::VecDeque;
+use std::sync::{Arc, Mutex};
+
+use axum::body::Bytes;
+use axum::extract::State;
+use axum::http::{header, HeaderMap, Method, StatusCode, Uri};
+use axum::response::{IntoResponse, Response};
+use axum::Router;
+use paimon::api::RestError;
+use paimon::catalog::{Catalog, Identifier, RESTCatalog};
+use paimon::common::Options;
+use paimon::Error;
+use serde_json::{json, Value};
+
+#[derive(Debug, PartialEq)]
+struct Request {
+    method: Method,
+    uri: String,
+    body: Option<Value>,
+}
+
+#[derive(Default)]
+struct ServerState {
+    responses: VecDeque<(StatusCode, String)>,
+    requests: Vec<Request>,
+}
+
+struct TestCatalog {
+    catalog: RESTCatalog,
+    state: Arc<Mutex<ServerState>>,
+    server: tokio::task::JoinHandle<()>,
+}
+
+impl Drop for TestCatalog {
+    fn drop(&mut self) {
+        self.server.abort();
+    }
+}
+
+impl TestCatalog {
+    async fn new(responses: Vec<(StatusCode, Value)>) -> Self {
+        Self::new_raw(
+            responses
+                .into_iter()
+                .map(|(status, body)| (status, body.to_string()))
+                .collect(),
+        )
+        .await
+    }
+
+    async fn new_raw(responses: Vec<(StatusCode, String)>) -> Self {
+        async fn handle(
+            State(state): State<Arc<Mutex<ServerState>>>,
+            method: Method,
+            uri: Uri,
+            headers: HeaderMap,
+            bytes: Bytes,
+        ) -> Response {
+            assert_eq!(headers["authorization"], "Bearer test-token");
+            let mut state = state.lock().unwrap();
+            state.requests.push(Request {
+                method,
+                uri: uri.to_string(),
+                body: (!bytes.is_empty()).then(|| 
serde_json::from_slice(&bytes).unwrap()),
+            });
+            let (status, value) = 
state.responses.pop_front().expect("unexpected request");
+            if status == StatusCode::NO_CONTENT {
+                status.into_response()
+            } else if matches!(status.as_u16(), 429 | 503) {
+                (status, [(header::RETRY_AFTER, "1")], value).into_response()
+            } else {
+                (status, value).into_response()
+            }
+        }
+        let state = Arc::new(Mutex::new(ServerState {
+            responses: responses.into(),
+            requests: vec![],
+        }));
+        let listener = 
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let address = listener.local_addr().unwrap();
+        let router = Router::new().fallback(handle).with_state(state.clone());
+        let server = tokio::spawn(async move { axum::serve(listener, 
router).await.unwrap() });
+        let mut options = Options::new();
+        options.set("uri", format!("http://{address}";));
+        options.set("warehouse", "warehouse");
+        options.set("prefix", "tenant /一");
+        options.set("token.provider", "bear");
+        options.set("token", "test-token");
+        let catalog = RESTCatalog::new(options, false).await.unwrap();
+        Self {
+            catalog,
+            state,
+            server,
+        }
+    }
+
+    fn requests(&self) -> Vec<Request> {
+        let mut state = self.state.lock().unwrap();
+        assert!(
+            state.responses.is_empty(),
+            "not all expected requests were sent"
+        );
+        std::mem::take(&mut state.requests)
+    }
+}
+
+#[tokio::test]
+async fn branch_mutations_ignore_success_response_bodies() {
+    let bodies = ["OK", "", "not JSON", "{}"];
+    let ctx = TestCatalog::new_raw(
+        bodies
+            .into_iter()
+            .flat_map(|body| vec![(StatusCode::OK, body.to_string()); 3])
+            .collect(),
+    )
+    .await;
+    let id = Identifier::new("db", "t");
+    for _ in bodies {
+        ctx.catalog.create_branch(&id, "dev", None).await.unwrap();
+        ctx.catalog.drop_branch(&id, "dev").await.unwrap();
+        ctx.catalog.fast_forward(&id, "dev").await.unwrap();
+    }
+    assert_eq!(ctx.requests().len(), bodies.len() * 3);
+}
+
+#[tokio::test]
+async fn branch_listing_retries_only_retryable_statuses() {
+    for status in [
+        StatusCode::TOO_MANY_REQUESTS,
+        StatusCode::SERVICE_UNAVAILABLE,
+    ] {
+        let ctx = TestCatalog::new(vec![
+            (
+                status,
+                json!({"code": status.as_u16(), "message": "try again"}),
+            ),
+            (StatusCode::OK, json!({"branches": ["dev"]})),
+        ])
+        .await;
+        assert_eq!(
+            ctx.catalog
+                .list_branches(&Identifier::new("db", "t"))
+                .await
+                .unwrap(),
+            ["dev"]
+        );
+        assert_eq!(ctx.requests().len(), 2);
+    }
+    let ctx = TestCatalog::new(vec![(
+        StatusCode::BAD_GATEWAY,
+        json!({"code": 502, "message": "gateway failed"}),
+    )])
+    .await;
+    assert!(matches!(
+        ctx.catalog.list_branches(&Identifier::new("db", "t")).await,
+        Err(Error::RestApi {
+            source: RestError::Unexpected { code: 502, .. }
+        })
+    ));
+    assert_eq!(ctx.requests().len(), 1);
+}
+
+#[tokio::test]
+async fn branch_mutations_do_not_retry_temporary_failures() {
+    let id = Identifier::new("db", "t");
+    for status in [
+        StatusCode::TOO_MANY_REQUESTS,
+        StatusCode::SERVICE_UNAVAILABLE,
+    ] {
+        let ctx = TestCatalog::new(vec![
+            (
+                status,
+                json!({"code": status.as_u16(), "message": "uncertain result"})
+            );
+            3
+        ])
+        .await;
+        assert!(ctx.catalog.create_branch(&id, "dev", None).await.is_err());
+        assert!(ctx.catalog.drop_branch(&id, "dev").await.is_err());
+        assert!(ctx.catalog.fast_forward(&id, "dev").await.is_err());
+        assert_eq!(ctx.requests().len(), 3);
+    }
+}
+
+#[tokio::test]
+async fn branch_listing_stops_after_five_retries() {
+    let ctx = TestCatalog::new(vec![
+        (
+            StatusCode::SERVICE_UNAVAILABLE,
+            json!({"code": 503, "message": "unavailable"})
+        );
+        6
+    ])
+    .await;
+    assert!(matches!(
+        ctx.catalog.list_branches(&Identifier::new("db", "t")).await,
+        Err(Error::RestApi {
+            source: RestError::ServiceUnavailable { .. }
+        })
+    ));
+    assert_eq!(ctx.requests().len(), 6);
+}
+
+#[tokio::test]
+async fn branch_protocol_preserves_names_tags_and_server_order() {
+    let ctx = TestCatalog::new(vec![
+        (StatusCode::NO_CONTENT, Value::Null),
+        (StatusCode::OK, json!({})),
+        (StatusCode::OK, json!({"branches": ["z", "a", "main"]})),
+        (StatusCode::NO_CONTENT, Value::Null),
+        (StatusCode::NO_CONTENT, Value::Null),
+    ])
+    .await;
+    let catalog: &dyn Catalog = &ctx.catalog;
+    let identifier = Identifier::new("db /", "table?$branch_dev");
+    catalog
+        .create_branch(&identifier, "branch /?#", None)
+        .await
+        .unwrap();
+    catalog
+        .create_branch(&identifier, "from-tag", Some("tag /一"))
+        .await
+        .unwrap();
+    assert_eq!(
+        catalog.list_branches(&identifier).await.unwrap(),
+        ["z", "a", "main"]
+    );
+    catalog
+        .fast_forward(&identifier, "branch /?#")
+        .await
+        .unwrap();
+    catalog
+        .drop_branch(&identifier, "branch /?#")
+        .await
+        .unwrap();
+    let base = 
"/v1/tenant+%2F%E4%B8%80/databases/db+%2F/tables/table%3F%24branch_dev/branches";
+    assert_eq!(
+        ctx.requests(),
+        vec![
+            Request {
+                method: Method::POST,
+                uri: base.into(),
+                body: Some(json!({"branch": "branch /?#", "fromTag": null}))
+            },
+            Request {
+                method: Method::POST,
+                uri: base.into(),
+                body: Some(json!({"branch": "from-tag", "fromTag": "tag /一"}))
+            },
+            Request {
+                method: Method::GET,
+                uri: base.into(),
+                body: None
+            },
+            Request {
+                method: Method::POST,
+                uri: format!("{base}/branch+%2F%3F%23/forward"),
+                body: Some(json!({}))
+            },
+            Request {
+                method: Method::DELETE,
+                uri: format!("{base}/branch+%2F%3F%23"),
+                body: None
+            },
+        ]
+    );
+}
+
+#[tokio::test]
+async fn null_or_absent_branches_are_empty_like_java() {
+    let ctx = TestCatalog::new(vec![
+        (StatusCode::OK, json!({"branches": null})),
+        (StatusCode::OK, json!({})),
+    ])
+    .await;
+    for _ in 0..2 {
+        assert!(ctx
+            .catalog
+            .list_branches(&Identifier::new("db", "t"))
+            .await
+            .unwrap()
+            .is_empty());
+    }
+    assert_eq!(ctx.requests().len(), 2);
+}
+
+#[tokio::test]
+async fn create_branch_maps_only_java_catalog_errors() {
+    let ctx = TestCatalog::new(vec![
+        (
+            StatusCode::NOT_FOUND,
+            json!({"code": 404, "resourceType": "TABLE", "message": "table 
missing"}),
+        ),
+        (
+            StatusCode::NOT_FOUND,
+            json!({"code": 404, "resourceType": "TAG", "message": "tag 
missing"}),
+        ),
+        (
+            StatusCode::CONFLICT,
+            json!({"code": 409, "message": "branch exists"}),
+        ),
+        (
+            StatusCode::BAD_REQUEST,
+            json!({"code": 400, "message": "invalid branch"}),
+        ),
+        (
+            StatusCode::NOT_FOUND,
+            json!({"code": 404, "resourceType": "OTHER", "message": "unknown 
resource"}),
+        ),
+    ])
+    .await;
+    let id = Identifier::new("db", "t");
+    assert!(
+        matches!(ctx.catalog.create_branch(&id, "dev", None).await, 
Err(Error::TableNotExist { full_name }) if full_name == "db.t")
+    );
+    assert!(
+        matches!(ctx.catalog.create_branch(&id, "dev", Some("base")).await, 
Err(Error::TagNotExist { tag_name }) if tag_name == "base")
+    );
+    assert!(
+        matches!(ctx.catalog.create_branch(&id, "dev", None).await, 
Err(Error::BranchAlreadyExist { branch_name, .. }) if branch_name == "dev")
+    );
+    assert!(
+        matches!(ctx.catalog.create_branch(&id, "dev", None).await, 
Err(Error::DataInvalid { message, .. }) if message == "invalid branch")
+    );
+    assert!(matches!(
+        ctx.catalog.create_branch(&id, "dev", None).await,
+        Err(Error::RestApi {
+            source: RestError::NoSuchResource { .. }
+        })
+    ));
+    assert_eq!(ctx.requests().len(), 5);
+}
+
+#[tokio::test]
+async fn missing_branch_and_table_errors_are_distinct() {
+    let missing = (
+        StatusCode::NOT_FOUND,
+        json!({"code": 404, "message": "missing"}),
+    );
+    let ctx = TestCatalog::new(vec![missing.clone(), missing.clone(), 
missing]).await;
+    let id = Identifier::new("db", "t");
+    assert!(
+        matches!(ctx.catalog.drop_branch(&id, "dev").await, 
Err(Error::BranchNotExist { full_name, branch_name }) if full_name == "db.t" && 
branch_name == "dev")
+    );
+    assert!(
+        matches!(ctx.catalog.fast_forward(&id, "dev").await, 
Err(Error::BranchNotExist { full_name, branch_name }) if full_name == "db.t" && 
branch_name == "dev")
+    );
+    assert!(
+        matches!(ctx.catalog.list_branches(&id).await, 
Err(Error::TableNotExist { full_name }) if full_name == "db.t")
+    );
+    assert_eq!(ctx.requests().len(), 3);
+}
+
+#[tokio::test]
+async fn branch_operations_preserve_forbidden_and_unsupported_responses() {
+    for status in [StatusCode::FORBIDDEN, StatusCode::NOT_IMPLEMENTED] {
+        let ctx = TestCatalog::new(vec![
+            (
+                status,
+                json!({"code": status.as_u16(), "message": "denied"})
+            );
+            4
+        ])
+        .await;
+        let id = Identifier::new("db", "t");
+        let results = [
+            ctx.catalog.create_branch(&id, "dev", None).await,
+            ctx.catalog.drop_branch(&id, "dev").await,
+            ctx.catalog.fast_forward(&id, "dev").await,
+            ctx.catalog.list_branches(&id).await.map(|_| ()),
+        ];
+        for result in results {
+            assert!(matches!(
+                result,
+                Err(Error::RestApi {
+                    source: RestError::Forbidden { .. } | 
RestError::NotImplemented { .. }
+                })
+            ));
+        }
+        assert_eq!(ctx.requests().len(), 4);
+    }
+}
+
+#[tokio::test]
+async fn branch_paths_cannot_address_parent_resources() {
+    let ctx = TestCatalog::new(vec![]).await;
+    let id = Identifier::new("db", "t");
+    for name in ["", " ", ".", ".."] {
+        assert!(matches!(
+            ctx.catalog.drop_branch(&id, name).await,
+            Err(Error::ConfigInvalid { .. })
+        ));
+        assert!(matches!(
+            ctx.catalog.fast_forward(&id, name).await,
+            Err(Error::ConfigInvalid { .. })
+        ));
+    }
+    for id in [Identifier::new("..", "t"), Identifier::new("db", ".")] {
+        assert!(matches!(
+            ctx.catalog.create_branch(&id, "dev", None).await,
+            Err(Error::ConfigInvalid { .. })
+        ));
+        assert!(matches!(
+            ctx.catalog.drop_branch(&id, "dev").await,
+            Err(Error::ConfigInvalid { .. })
+        ));
+        assert!(matches!(
+            ctx.catalog.fast_forward(&id, "dev").await,
+            Err(Error::ConfigInvalid { .. })
+        ));
+        assert!(matches!(
+            ctx.catalog.list_branches(&id).await,
+            Err(Error::ConfigInvalid { .. })
+        ));
+    }
+    assert!(ctx.requests().is_empty());
+}

Reply via email to