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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 1a4a1e60ed9 branch-4.2: [fix](catalog) re-auth Iceberg REST catalog on 
401 instead of wedging until FE restart (#70030)
1a4a1e60ed9 is described below

commit 1a4a1e60ed9622340b3308b4ebae57ab4a097c0a
Author: Calvin Kirs <[email protected]>
AuthorDate: Thu Nov 5 09:42:29 2026 +0800

    branch-4.2: [fix](catalog) re-auth Iceberg REST catalog on 401 instead of 
wedging until FE restart (#70030)
    
    https://github.com/apache/doris/pull/64966
    
    Co-authored-by: Raghvendra Singh <[email protected]>
---
 .../datasource/iceberg/IcebergExternalCatalog.java |   4 +-
 .../datasource/iceberg/IcebergMetadataOps.java     |   6 +-
 .../iceberg/IcebergRestExternalCatalog.java        |   4 +-
 .../iceberg/IcebergUserSessionCatalog.java         |   4 +-
 .../ReauthenticatingRestSessionCatalog.java        | 283 +++++++++++++++++++++
 .../property/metastore/IcebergRestProperties.java  |  33 ++-
 .../ReauthenticatingRestSessionCatalogTest.java    | 191 ++++++++++++++
 7 files changed, 510 insertions(+), 15 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalog.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalog.java
index 71a8de40dc7..9e3001386a1 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalog.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalCatalog.java
@@ -43,8 +43,8 @@ import 
org.apache.doris.foundation.security.JdbcDriverUrlSecurity;
 import org.apache.doris.transaction.TransactionManagerFactory;
 
 import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.BaseViewSessionCatalog;
 import org.apache.iceberg.catalog.Catalog;
-import org.apache.iceberg.rest.RESTSessionCatalog;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
@@ -286,7 +286,7 @@ public abstract class IcebergExternalCatalog extends 
ExternalCatalog {
         ExternalMetaCacheMgr cacheMgr = 
Env.getCurrentEnv().getExtMetaCacheMgr();
         cacheMgr.removeCatalogByEngine(getId(), 
IcebergExternalMetaCache.ENGINE);
         Catalog retiredCatalog = catalog;
-        RESTSessionCatalog retiredRestCatalog = msProperties instanceof 
IcebergRestProperties
+        BaseViewSessionCatalog retiredRestCatalog = msProperties instanceof 
IcebergRestProperties
                 ? ((IcebergRestProperties) 
msProperties).getRestSessionCatalog() : null;
         catalog = null;
         resourceTracker.retireCurrent(() -> {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
index 728f4a1c3cd..b200b40ea46 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
@@ -72,6 +72,7 @@ import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.UpdatePartitionSpec;
 import org.apache.iceberg.UpdateProperties;
 import org.apache.iceberg.UpdateSchema;
+import org.apache.iceberg.catalog.BaseViewSessionCatalog;
 import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.SessionCatalog;
@@ -86,7 +87,6 @@ import org.apache.iceberg.expressions.Term;
 import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.io.LocationProvider;
 import org.apache.iceberg.jdbc.JdbcCatalog;
-import org.apache.iceberg.rest.RESTSessionCatalog;
 import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.TypeUtil;
 import org.apache.iceberg.types.Types;
@@ -151,7 +151,7 @@ public class IcebergMetadataOps implements 
ExternalMetadataOps {
         // Other catalog types have no session-aware capability.
         this.userSessionCatalog = dorisCatalog instanceof 
IcebergUserSessionCatalog
                 ? (IcebergUserSessionCatalog) dorisCatalog : null;
-        RESTSessionCatalog restSessionCatalog =
+        BaseViewSessionCatalog restSessionCatalog =
                 userSessionCatalog == null ? null : 
userSessionCatalog.getRestSessionCatalog();
         IcebergRestProperties.DelegatedTokenMode delegatedTokenMode =
                 userSessionCatalog == null ? 
IcebergRestProperties.DelegatedTokenMode.ACCESS_TOKEN
@@ -2193,7 +2193,7 @@ public class IcebergMetadataOps implements 
ExternalMetadataOps {
         return defaultViewCatalog;
     }
 
-    private Optional<ViewCatalog> resolveDefaultViewCatalog(Catalog catalog, 
RESTSessionCatalog restSessionCatalog,
+    private Optional<ViewCatalog> resolveDefaultViewCatalog(Catalog catalog, 
BaseViewSessionCatalog restSessionCatalog,
             boolean viewEnabled) {
         // Branch on whether this is a REST (session-aware) catalog, not on 
whether restSessionCatalog happens to
         // be built: for REST the default Catalog (asCatalog) is not a 
ViewCatalog, so views must come from the
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRestExternalCatalog.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRestExternalCatalog.java
index 3a70e945180..431941365eb 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRestExternalCatalog.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergRestExternalCatalog.java
@@ -29,7 +29,7 @@ import 
org.apache.doris.datasource.property.metastore.IcebergRestProperties;
 import org.apache.doris.datasource.property.metastore.MetastoreProperties;
 
 import com.google.common.collect.Lists;
-import org.apache.iceberg.rest.RESTSessionCatalog;
+import org.apache.iceberg.catalog.BaseViewSessionCatalog;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
@@ -213,7 +213,7 @@ public class IcebergRestExternalCatalog extends 
IcebergExternalCatalog implement
     }
 
     @Override
-    public RESTSessionCatalog getRestSessionCatalog() {
+    public BaseViewSessionCatalog getRestSessionCatalog() {
         IcebergRestProperties props = restProperties();
         return props == null ? null : props.getRestSessionCatalog();
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUserSessionCatalog.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUserSessionCatalog.java
index 4aae96ead4a..e0d8b1c5b39 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUserSessionCatalog.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUserSessionCatalog.java
@@ -20,7 +20,7 @@ package org.apache.doris.datasource.iceberg;
 import org.apache.doris.datasource.SessionContext;
 import org.apache.doris.datasource.property.metastore.IcebergRestProperties;
 
-import org.apache.iceberg.rest.RESTSessionCatalog;
+import org.apache.iceberg.catalog.BaseViewSessionCatalog;
 
 /**
  * Capability interface for an Iceberg catalog that supports per-user dynamic 
session
@@ -47,7 +47,7 @@ public interface IcebergUserSessionCatalog {
     boolean useSessionCatalog(SessionContext ctx);
 
     /** The session-aware Iceberg REST catalog backing this catalog (may be 
null before initialization). */
-    RESTSessionCatalog getRestSessionCatalog();
+    BaseViewSessionCatalog getRestSessionCatalog();
 
     /** The delegated-token mode used when attaching the user's credential to 
session requests. */
     IcebergRestProperties.DelegatedTokenMode getDelegatedTokenMode();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalog.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalog.java
new file mode 100644
index 00000000000..c39b0a73381
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalog.java
@@ -0,0 +1,283 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.iceberg;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.BaseViewSessionCatalog;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.SessionCatalog.SessionContext;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NotAuthorizedException;
+import org.apache.iceberg.rest.RESTSessionCatalog;
+import org.apache.iceberg.view.View;
+import org.apache.iceberg.view.ViewBuilder;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+
+/**
+ * A session catalog that transparently recovers from an expired/rejected 
credential on an Iceberg REST
+ * catalog, instead of failing every request until the FE restarts.
+ *
+ * <p><b>Why this exists.</b> {@link RESTSessionCatalog} obtains an OAuth2 
token at {@code initialize()} and
+ * keeps it refreshed in the background. If a refresh permanently fails (e.g. 
the auth server was briefly
+ * unreachable or overloaded at refresh time), the client is left holding a 
stale token and every subsequent
+ * request fails with {@link NotAuthorizedException} (HTTP 401) forever — the 
client has no re-authentication
+ * path of its own, and neither {@code REFRESH CATALOG} nor metadata-cache 
invalidation rebuilds it. The only
+ * recovery is building a fresh client, which is exactly what this wrapper 
does: on a 401 from a request made
+ * under the catalog's own identity, it rebuilds the delegate {@link 
RESTSessionCatalog} (forcing a fresh
+ * OAuth2 token fetch), closes the wedged one, and retries the operation once. 
If the retry also fails, the
+ * error propagates unchanged.
+ *
+ * <p><b>Why a wrapper at the session-catalog level.</b> Everything Doris 
hands out for a REST catalog — the
+ * default {@code asCatalog(empty)} used by {@code IcebergMetadataOps}, the 
{@code asViewCatalog(empty)} view
+ * path, and per-user delegated sessions — is a thin view that calls back into 
this class (see
+ * {@link org.apache.iceberg.catalog.BaseSessionCatalog.AsCatalog}). Wrapping 
here means no holder of any of
+ * those references ever sees a stale client after recovery, and non-REST 
catalogs are untouched because only
+ * {@code IcebergRestProperties} constructs this class.
+ *
+ * <p><b>What is retried.</b> Both reads and mutations: a 401 is rejected by 
the server before the request is
+ * processed, so retrying after re-authentication cannot double-apply an 
operation. Requests that carry a
+ * per-user delegated credential are <b>not</b> recovered — a 401 there means 
that user's token is invalid,
+ * and rebuilding the shared client cannot (and must not) fix it.
+ *
+ * <p><b>Boundary.</b> Recovery triggers on catalog-level operations. Objects 
already handed out (a loaded
+ * {@link Table}/{@link View}, a builder from {@code buildTable}/{@code 
buildView}) keep their own reference
+ * to the client they were created with; if the credential expires between 
obtaining such an object and using
+ * it, that one use can still fail with a 401. The next catalog-level 
operation rebuilds the client, after
+ * which reloading the object succeeds.
+ */
+public class ReauthenticatingRestSessionCatalog extends BaseViewSessionCatalog 
implements Closeable {
+
+    private static final Logger LOG = 
LogManager.getLogger(ReauthenticatingRestSessionCatalog.class);
+
+    private final Supplier<RESTSessionCatalog> delegateBuilder;
+    private volatile RESTSessionCatalog delegate;
+
+    public ReauthenticatingRestSessionCatalog(RESTSessionCatalog 
initialDelegate,
+            Supplier<RESTSessionCatalog> delegateBuilder) {
+        this.delegate = initialDelegate;
+        this.delegateBuilder = delegateBuilder;
+    }
+
+    @VisibleForTesting
+    RESTSessionCatalog currentDelegate() {
+        return delegate;
+    }
+
+    private <T> T withAuthRecovery(SessionContext context, Supplier<T> op) {
+        RESTSessionCatalog attemptedOn = delegate;
+        try {
+            return op.get();
+        } catch (RuntimeException e) {
+            if (!isAuthExpired(e) || !usesCatalogIdentity(context)) {
+                throw e;
+            }
+            reauthenticate(attemptedOn, e);
+            return op.get();
+        }
+    }
+
+    private void runWithAuthRecovery(SessionContext context, Runnable op) {
+        withAuthRecovery(context, () -> {
+            op.run();
+            return null;
+        });
+    }
+
+    /**
+     * Rebuilds the delegate (fresh client, fresh token) and closes the wedged 
one. Synchronized so that
+     * concurrent 401s coalesce into a single rebuild: a thread whose failed 
attempt ran against an
+     * already-replaced delegate skips the rebuild and just retries on the 
fresh one.
+     */
+    private synchronized void reauthenticate(RESTSessionCatalog attemptedOn, 
RuntimeException cause) {
+        if (delegate != attemptedOn) {
+            return;
+        }
+        LOG.warn("Iceberg REST catalog {} rejected its cached credential (401 
Not Authorized) and the client "
+                + "cannot recover it internally. Rebuilding the REST client to 
force re-authentication, "
+                + "then retrying the request once.", name(), cause);
+        RESTSessionCatalog replacement = delegateBuilder.get();
+        RESTSessionCatalog wedged = delegate;
+        delegate = replacement;
+        try {
+            wedged.close();
+        } catch (IOException | RuntimeException e) {
+            LOG.warn("Failed to close the replaced Iceberg REST client of 
catalog {}", name(), e);
+        }
+    }
+
+    /**
+     * A request made with a per-user delegated credential authenticates as 
that user, not as the catalog;
+     * a 401 there is that token's problem and must not rebuild (or leak into) 
the shared client.
+     */
+    private static boolean usesCatalogIdentity(SessionContext context) {
+        return context == null || context.credentials() == null || 
context.credentials().isEmpty();
+    }
+
+    private static boolean isAuthExpired(Throwable t) {
+        return ExceptionUtils.getThrowableList(t).stream().anyMatch(c -> c 
instanceof NotAuthorizedException);
+    }
+
+    // ---- SessionCatalog ----
+
+    @Override
+    public void initialize(String name, Map<String, String> properties) {
+        super.initialize(name, properties);
+        delegate.initialize(name, properties);
+    }
+
+    @Override
+    public String name() {
+        return delegate.name();
+    }
+
+    @Override
+    public Map<String, String> properties() {
+        return delegate.properties();
+    }
+
+    @Override
+    public List<TableIdentifier> listTables(SessionContext context, Namespace 
ns) {
+        return withAuthRecovery(context, () -> delegate.listTables(context, 
ns));
+    }
+
+    @Override
+    public Catalog.TableBuilder buildTable(SessionContext context, 
TableIdentifier ident, Schema schema) {
+        return withAuthRecovery(context, () -> delegate.buildTable(context, 
ident, schema));
+    }
+
+    @Override
+    public Table registerTable(SessionContext context, TableIdentifier ident, 
String metadataFileLocation) {
+        return withAuthRecovery(context, () -> delegate.registerTable(context, 
ident, metadataFileLocation));
+    }
+
+    @Override
+    public boolean tableExists(SessionContext context, TableIdentifier ident) {
+        return withAuthRecovery(context, () -> delegate.tableExists(context, 
ident));
+    }
+
+    @Override
+    public Table loadTable(SessionContext context, TableIdentifier ident) {
+        return withAuthRecovery(context, () -> delegate.loadTable(context, 
ident));
+    }
+
+    @Override
+    public boolean dropTable(SessionContext context, TableIdentifier ident) {
+        return withAuthRecovery(context, () -> delegate.dropTable(context, 
ident));
+    }
+
+    @Override
+    public boolean purgeTable(SessionContext context, TableIdentifier ident) {
+        return withAuthRecovery(context, () -> delegate.purgeTable(context, 
ident));
+    }
+
+    @Override
+    public void renameTable(SessionContext context, TableIdentifier from, 
TableIdentifier to) {
+        runWithAuthRecovery(context, () -> delegate.renameTable(context, from, 
to));
+    }
+
+    @Override
+    public void invalidateTable(SessionContext context, TableIdentifier ident) 
{
+        runWithAuthRecovery(context, () -> delegate.invalidateTable(context, 
ident));
+    }
+
+    @Override
+    public void createNamespace(SessionContext context, Namespace namespace, 
Map<String, String> metadata) {
+        runWithAuthRecovery(context, () -> delegate.createNamespace(context, 
namespace, metadata));
+    }
+
+    @Override
+    public List<Namespace> listNamespaces(SessionContext context, Namespace 
namespace) {
+        return withAuthRecovery(context, () -> 
delegate.listNamespaces(context, namespace));
+    }
+
+    @Override
+    public Map<String, String> loadNamespaceMetadata(SessionContext context, 
Namespace namespace) {
+        return withAuthRecovery(context, () -> 
delegate.loadNamespaceMetadata(context, namespace));
+    }
+
+    @Override
+    public boolean dropNamespace(SessionContext context, Namespace namespace) {
+        return withAuthRecovery(context, () -> delegate.dropNamespace(context, 
namespace));
+    }
+
+    @Override
+    public boolean updateNamespaceMetadata(SessionContext context, Namespace 
namespace,
+            Map<String, String> updates, Set<String> removals) {
+        return withAuthRecovery(context, () -> 
delegate.updateNamespaceMetadata(context, namespace, updates,
+                removals));
+    }
+
+    @Override
+    public boolean namespaceExists(SessionContext context, Namespace 
namespace) {
+        return withAuthRecovery(context, () -> 
delegate.namespaceExists(context, namespace));
+    }
+
+    // ---- ViewSessionCatalog ----
+
+    @Override
+    public List<TableIdentifier> listViews(SessionContext context, Namespace 
namespace) {
+        return withAuthRecovery(context, () -> delegate.listViews(context, 
namespace));
+    }
+
+    @Override
+    public View loadView(SessionContext context, TableIdentifier identifier) {
+        return withAuthRecovery(context, () -> delegate.loadView(context, 
identifier));
+    }
+
+    @Override
+    public boolean viewExists(SessionContext context, TableIdentifier 
identifier) {
+        return withAuthRecovery(context, () -> delegate.viewExists(context, 
identifier));
+    }
+
+    @Override
+    public ViewBuilder buildView(SessionContext context, TableIdentifier 
identifier) {
+        return withAuthRecovery(context, () -> delegate.buildView(context, 
identifier));
+    }
+
+    @Override
+    public boolean dropView(SessionContext context, TableIdentifier 
identifier) {
+        return withAuthRecovery(context, () -> delegate.dropView(context, 
identifier));
+    }
+
+    @Override
+    public void renameView(SessionContext context, TableIdentifier from, 
TableIdentifier to) {
+        runWithAuthRecovery(context, () -> delegate.renameView(context, from, 
to));
+    }
+
+    @Override
+    public void invalidateView(SessionContext context, TableIdentifier 
identifier) {
+        runWithAuthRecovery(context, () -> delegate.invalidateView(context, 
identifier));
+    }
+
+    @Override
+    public void close() throws IOException {
+        delegate.close();
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java
index eee0fc52972..01ac81eeb2d 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java
@@ -21,6 +21,7 @@ import org.apache.doris.datasource.DelegatedCredential;
 import org.apache.doris.datasource.SessionContext;
 import org.apache.doris.datasource.iceberg.IcebergDelegatedCredentialUtils;
 import org.apache.doris.datasource.iceberg.IcebergExternalCatalog;
+import org.apache.doris.datasource.iceberg.ReauthenticatingRestSessionCatalog;
 import org.apache.doris.datasource.property.common.AwsCredentialsProviderMode;
 import 
org.apache.doris.datasource.property.common.IcebergAwsClientCredentialsProperties;
 import org.apache.doris.datasource.property.storage.OSSProperties;
@@ -33,6 +34,7 @@ import lombok.Getter;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.catalog.BaseViewSessionCatalog;
 import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.SessionCatalog;
 import org.apache.iceberg.rest.RESTSessionCatalog;
@@ -42,6 +44,7 @@ import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.apache.logging.log4j.util.Strings;
 
+import java.io.Closeable;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
@@ -68,7 +71,10 @@ public class IcebergRestProperties extends 
AbstractIcebergProperties {
     // asCatalog(SessionContext) / asViewCatalog(SessionContext), without 
reflecting RESTCatalog's private
     // sessionCatalog field. This is the single underlying catalog shared by 
the default and user-session
     // paths; IcebergMetadataOps reads it via getRestSessionCatalog() and owns 
no other REST catalog.
-    private RESTSessionCatalog restSessionCatalog;
+    // When the catalog authenticates with its own identity this is a 
ReauthenticatingRestSessionCatalog
+    // wrapping the RESTSessionCatalog, so an expired/rejected token is 
recovered by rebuilding the client
+    // instead of failing every request until the FE restarts.
+    private BaseViewSessionCatalog restSessionCatalog;
 
     @Getter
     @ConnectorProperty(names = {"iceberg.rest.uri", "uri"},
@@ -243,7 +249,22 @@ public class IcebergRestProperties extends 
AbstractIcebergProperties {
         // it ourselves keeps that capability without reflection. The "type" 
key is dropped because the
         // Iceberg SDK rejects "type" together with a concrete catalog impl.
         catalogProps.remove(CatalogUtil.ICEBERG_CATALOG_TYPE);
-        this.restSessionCatalog = buildRestSessionCatalog(catalogName, 
catalogProps, configuration);
+        RESTSessionCatalog rawSessionCatalog = 
buildRestSessionCatalog(catalogName, catalogProps, configuration);
+        if (sessionContext == null || 
!sessionContext.hasDelegatedCredential()) {
+            // The catalog authenticates with its own identity (e.g. an oauth2 
client credential). If that
+            // credential's token expires and the client cannot refresh it 
(auth server briefly unreachable at
+            // refresh time), the RESTSessionCatalog is left rejecting every 
request with a 401 until the FE
+            // restarts. Wrap it so a 401 rebuilds the client (fresh token) 
and retries once. The rebuild
+            // supplier re-resolves from the same catalog-identity properties, 
so it can never capture a
+            // per-user delegated credential.
+            Map<String, String> frozenProps = Collections.unmodifiableMap(new 
HashMap<>(catalogProps));
+            this.restSessionCatalog = new 
ReauthenticatingRestSessionCatalog(rawSessionCatalog,
+                    () -> buildRestSessionCatalog(catalogName, frozenProps, 
configuration));
+        } else {
+            // Catalog initialization under a per-user delegated credential: 
recovery must not re-mint the
+            // client with that user's token, so no wrapper — behavior is 
unchanged from before.
+            this.restSessionCatalog = rawSessionCatalog;
+        }
         // The default (non-delegated) Catalog is asCatalog(empty), identical 
to what RESTCatalog exposes.
         return 
restSessionCatalog.asCatalog(SessionCatalog.SessionContext.createEmpty());
     }
@@ -265,7 +286,7 @@ public class IcebergRestProperties extends 
AbstractIcebergProperties {
      * catalog has not been initialized yet. Callers use it to obtain 
per-request catalogs via
      * {@code asCatalog(SessionContext)} / {@code 
asViewCatalog(SessionContext)}.
      */
-    public RESTSessionCatalog getRestSessionCatalog() {
+    public BaseViewSessionCatalog getRestSessionCatalog() {
         return restSessionCatalog;
     }
 
@@ -274,14 +295,14 @@ public class IcebergRestProperties extends 
AbstractIcebergProperties {
      * Safe to call multiple times and before initialization.
      */
     public void closeRestSessionCatalog() {
-        if (restSessionCatalog != null) {
+        if (restSessionCatalog instanceof Closeable) {
             try {
-                restSessionCatalog.close();
+                ((Closeable) restSessionCatalog).close();
             } catch (IOException e) {
                 LOG.warn("Failed to close Iceberg REST session catalog", e);
             }
-            restSessionCatalog = null;
         }
+        restSessionCatalog = null;
     }
 
     @Override
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalogTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalogTest.java
new file mode 100644
index 00000000000..2132a38febb
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/ReauthenticatingRestSessionCatalogTest.java
@@ -0,0 +1,191 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.iceberg;
+
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.SessionCatalog.SessionContext;
+import org.apache.iceberg.exceptions.NotAuthorizedException;
+import org.apache.iceberg.rest.RESTSessionCatalog;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class ReauthenticatingRestSessionCatalogTest {
+
+    private static final Namespace NS = Namespace.empty();
+
+    /**
+     * A stand-in for a live REST catalog: throws the configured failure on 
every namespace listing until the
+     * failure is cleared, and records whether it was closed. All overridden 
methods avoid the real
+     * RESTSessionCatalog internals, so no initialization or network is 
involved.
+     */
+    private static final class FakeRestSessionCatalog extends 
RESTSessionCatalog {
+        private final String label;
+        private final RuntimeException failure;
+        private final AtomicInteger listCalls = new AtomicInteger();
+        private volatile boolean closed;
+
+        FakeRestSessionCatalog(String label, RuntimeException failure) {
+            this.label = label;
+            this.failure = failure;
+        }
+
+        @Override
+        public String name() {
+            return label;
+        }
+
+        @Override
+        public List<Namespace> listNamespaces(SessionContext context, 
Namespace ns) {
+            listCalls.incrementAndGet();
+            if (failure != null) {
+                throw failure;
+            }
+            return Collections.singletonList(Namespace.of(label));
+        }
+
+        @Override
+        public void close() {
+            closed = true;
+        }
+    }
+
+    private static NotAuthorizedException notAuthorized() {
+        return new NotAuthorizedException("Not authorized: %s", "the token 
expired");
+    }
+
+    private static SessionContext delegatedUserContext() {
+        return new SessionContext(UUID.randomUUID().toString(), "alice",
+                Collections.singletonMap("token", "user-token"), 
Collections.emptyMap());
+    }
+
+    @Test
+    public void testNotAuthorizedRebuildsClientAndRetriesOnce() {
+        FakeRestSessionCatalog wedged = new FakeRestSessionCatalog("wedged", 
notAuthorized());
+        FakeRestSessionCatalog fresh = new FakeRestSessionCatalog("fresh", 
null);
+        AtomicInteger rebuilds = new AtomicInteger();
+        ReauthenticatingRestSessionCatalog catalog = new 
ReauthenticatingRestSessionCatalog(wedged, () -> {
+            rebuilds.incrementAndGet();
+            return fresh;
+        });
+
+        List<Namespace> namespaces = 
catalog.listNamespaces(SessionContext.createEmpty(), NS);
+
+        
Assertions.assertEquals(Collections.singletonList(Namespace.of("fresh")), 
namespaces);
+        Assertions.assertEquals(1, rebuilds.get());
+        Assertions.assertEquals(1, wedged.listCalls.get());
+        Assertions.assertEquals(1, fresh.listCalls.get());
+        Assertions.assertTrue(wedged.closed, "the wedged client must be closed 
after replacement");
+        Assertions.assertSame(fresh, catalog.currentDelegate());
+    }
+
+    @Test
+    public void testNotAuthorizedWrappedInAnotherExceptionIsDetected() {
+        FakeRestSessionCatalog wedged = new FakeRestSessionCatalog("wedged",
+                new RuntimeException("Failed to list database names", 
notAuthorized()));
+        FakeRestSessionCatalog fresh = new FakeRestSessionCatalog("fresh", 
null);
+        ReauthenticatingRestSessionCatalog catalog =
+                new ReauthenticatingRestSessionCatalog(wedged, () -> fresh);
+
+        List<Namespace> namespaces = 
catalog.listNamespaces(SessionContext.createEmpty(), NS);
+
+        
Assertions.assertEquals(Collections.singletonList(Namespace.of("fresh")), 
namespaces);
+    }
+
+    @Test
+    public void testStillNotAuthorizedAfterRebuildPropagatesWithoutLooping() {
+        FakeRestSessionCatalog wedged = new FakeRestSessionCatalog("wedged", 
notAuthorized());
+        FakeRestSessionCatalog stillWedged = new 
FakeRestSessionCatalog("still-wedged", notAuthorized());
+        AtomicInteger rebuilds = new AtomicInteger();
+        ReauthenticatingRestSessionCatalog catalog = new 
ReauthenticatingRestSessionCatalog(wedged, () -> {
+            rebuilds.incrementAndGet();
+            return stillWedged;
+        });
+
+        Assertions.assertThrows(NotAuthorizedException.class,
+                () -> catalog.listNamespaces(SessionContext.createEmpty(), 
NS));
+        Assertions.assertEquals(1, rebuilds.get(), "exactly one rebuild, no 
retry loop");
+        Assertions.assertEquals(1, wedged.listCalls.get());
+        Assertions.assertEquals(1, stillWedged.listCalls.get());
+    }
+
+    @Test
+    public void testNonAuthFailuresAreNotRetried() {
+        FakeRestSessionCatalog failing = new FakeRestSessionCatalog("failing",
+                new RuntimeException("connection reset"));
+        AtomicInteger rebuilds = new AtomicInteger();
+        ReauthenticatingRestSessionCatalog catalog = new 
ReauthenticatingRestSessionCatalog(failing, () -> {
+            rebuilds.incrementAndGet();
+            return new FakeRestSessionCatalog("fresh", null);
+        });
+
+        Assertions.assertThrows(RuntimeException.class,
+                () -> catalog.listNamespaces(SessionContext.createEmpty(), 
NS));
+        Assertions.assertEquals(0, rebuilds.get());
+        Assertions.assertEquals(1, failing.listCalls.get());
+        Assertions.assertFalse(failing.closed);
+    }
+
+    @Test
+    public void testDelegatedUserSessionIsNotRecovered() {
+        // A 401 for a request carrying a per-user delegated credential means 
that user's token is invalid.
+        // Rebuilding the shared client cannot fix it and must not be 
triggered by it.
+        FakeRestSessionCatalog wedged = new FakeRestSessionCatalog("wedged", 
notAuthorized());
+        AtomicInteger rebuilds = new AtomicInteger();
+        ReauthenticatingRestSessionCatalog catalog = new 
ReauthenticatingRestSessionCatalog(wedged, () -> {
+            rebuilds.incrementAndGet();
+            return new FakeRestSessionCatalog("fresh", null);
+        });
+
+        Assertions.assertThrows(NotAuthorizedException.class,
+                () -> catalog.listNamespaces(delegatedUserContext(), NS));
+        Assertions.assertEquals(0, rebuilds.get());
+        Assertions.assertFalse(wedged.closed);
+    }
+
+    @Test
+    public void testAsCatalogViewRoutesThroughRecovery() {
+        // The default Catalog handed to IcebergMetadataOps is 
asCatalog(empty); it must inherit the same
+        // recovery because it calls back into this session catalog.
+        FakeRestSessionCatalog wedged = new FakeRestSessionCatalog("wedged", 
notAuthorized());
+        FakeRestSessionCatalog fresh = new FakeRestSessionCatalog("fresh", 
null);
+        ReauthenticatingRestSessionCatalog catalog =
+                new ReauthenticatingRestSessionCatalog(wedged, () -> fresh);
+
+        List<Namespace> namespaces = 
((org.apache.iceberg.catalog.SupportsNamespaces)
+                
catalog.asCatalog(SessionContext.createEmpty())).listNamespaces(NS);
+
+        
Assertions.assertEquals(Collections.singletonList(Namespace.of("fresh")), 
namespaces);
+        Assertions.assertTrue(wedged.closed);
+    }
+
+    @Test
+    public void testCloseClosesCurrentDelegate() throws Exception {
+        FakeRestSessionCatalog delegate = new 
FakeRestSessionCatalog("delegate", null);
+        ReauthenticatingRestSessionCatalog catalog =
+                new ReauthenticatingRestSessionCatalog(delegate, () -> 
delegate);
+
+        catalog.close();
+
+        Assertions.assertTrue(delegate.closed);
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to