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

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


The following commit(s) were added to refs/heads/main by this push:
     new 30a11bb0cd [#14516] fix(catalog-glue): Map AWS errors to client-facing 
statuses and validate catalog properties (#14517)
30a11bb0cd is described below

commit 30a11bb0cd72699a5e1f3ddc9f6e2fca230f1c3a
Author: Yuhui <[email protected]>
AuthorDate: Tue Nov 3 16:25:34 2026 +0800

    [#14516] fix(catalog-glue): Map AWS errors to client-facing statuses and 
validate catalog properties (#14517)
    
    ### What changes were proposed in this pull request?
    
    - Map Glue SDK failures in one place: rejected credentials to 403,
    region/endpoint mismatch and invalid input to 400, throttling to 503,
    unreachable endpoint or timeout to 502. Long AWS messages are shortened.
    - Use the same mapping for partition and Iceberg operations, and map
    `ForbiddenException` to 403 in the partition REST handler.
    - Validate catalog properties at create and alter: known lower-case
    region, 12-digit catalog ID, http(s) endpoint, warehouse URI with a
    scheme (Iceberg tables need an S3 location), documented table format
    values. At create, also check the key pair locally and reject temporary
    keys. No AWS call is made.
    - Add `aws-glue-api-call-timeout-ms`,
    `aws-glue-api-call-attempt-timeout-ms` and `aws-glue-max-attempts`, also
    applied to the Iceberg Glue client.
    - Apply `table-format-filter` to load, alter, drop and create.
    - Return `false` when dropping a missing database without cascade.
    
    ### Why are the changes needed?
    
    Client-side and configuration errors were reported as server errors, and
    invalid catalogs were stored and failed only on first use.
    
    Fixes #14516
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    - New catalog properties: `aws-glue-api-call-timeout-ms` (default
    30000), `aws-glue-api-call-attempt-timeout-ms` (default 10000),
    `aws-glue-max-attempts` (default 4).
    - Invalid catalog property values are now rejected at create and alter.
    - HTTP status codes of the Glue error cases above change from 500 to
    4xx/502/503.
    - `aws-region` must be a region known to the bundled AWS SDK; a newly
    launched region needs an SDK upgrade.
    
    ### How was this patch tested?
    
    - Added unit tests in `catalog-glue` and `TestExceptionHandlers`.
    - Added Moto ITs for drop, format filter, partition value and endpoint
    errors; ran `MotoGlueCatalogIT` locally (32 passed).
    - Ran `AwsGlueCatalogIT` against real AWS Glue (32 passed).
---
 .../gravitino/catalog/glue/GlueConstants.java      |  31 ++
 .../apache/gravitino/catalog/glue/GlueCatalog.java |  28 ++
 .../catalog/glue/GlueCatalogOperations.java        | 110 ++++---
 .../glue/GlueCatalogPropertiesMetadata.java        | 237 ++++++++++++++-
 .../gravitino/catalog/glue/GlueClientProvider.java | 128 +++++++-
 .../catalog/glue/GlueExceptionConverter.java       | 324 ++++++++++++++++++---
 .../catalog/glue/GlueIcebergTableHelper.java       |  48 ++-
 .../catalog/glue/GlueTableOperations.java          |  46 ++-
 .../catalog/glue/TestGlueCatalogCredentials.java   |  36 +++
 .../glue/TestGlueCatalogOperationsForIceberg.java  |  90 ++++++
 .../glue/TestGlueCatalogPropertiesMetadata.java    |  78 +++++
 .../glue/TestGlueCatalogSchemaOperations.java      | 113 ++++++-
 .../glue/TestGlueCatalogTableOperations.java       | 108 +++++++
 .../catalog/glue/TestGlueClientProvider.java       |  82 ++++++
 .../catalog/glue/TestGlueExceptionConverter.java   | 243 ++++++++++++++++
 .../catalog/glue/TestGlueIcebergTableHelper.java   |  14 +
 .../catalog/glue/TestGluePartitionOperations.java  |  83 ++++++
 .../integration/test/AbstractGlueCatalogIT.java    |  56 ++++
 .../glue/integration/test/MotoGlueCatalogIT.java   |  58 ++++
 docs/aws-glue-catalog.md                           |  23 +-
 .../server/web/rest/ExceptionHandlers.java         |   3 +
 .../server/web/rest/TestExceptionHandlers.java     |  16 +
 22 files changed, 1825 insertions(+), 130 deletions(-)

diff --git 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
index 0163c43110..a1a3ea62e7 100644
--- 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
+++ 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
@@ -51,6 +51,37 @@ public final class GlueConstants {
    */
   public static final String AWS_GLUE_ENDPOINT = "aws-glue-endpoint";
 
+  /**
+   * Upper bound in milliseconds for one Glue API call made by the catalog, 
including all retries
+   * (optional). The AWS clients used for Iceberg tables only take the attempt 
timeout. Defaults to
+   * {@link #DEFAULT_AWS_GLUE_API_CALL_TIMEOUT_MS}.
+   */
+  public static final String AWS_GLUE_API_CALL_TIMEOUT_MS = 
"aws-glue-api-call-timeout-ms";
+
+  /** Default value for {@link #AWS_GLUE_API_CALL_TIMEOUT_MS}: 30 seconds. */
+  public static final long DEFAULT_AWS_GLUE_API_CALL_TIMEOUT_MS = 30_000L;
+
+  /**
+   * Upper bound in milliseconds for a single attempt of a Glue API call 
(optional). Also used as
+   * the HTTP connection and socket timeout of the AWS clients (Glue and S3) 
used for Iceberg
+   * tables. Defaults to {@link #DEFAULT_AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS}.
+   */
+  public static final String AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS =
+      "aws-glue-api-call-attempt-timeout-ms";
+
+  /** Default value for {@link #AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS}: 10 
seconds. */
+  public static final long DEFAULT_AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS = 
10_000L;
+
+  /**
+   * Maximum number of attempts of a Glue API call made by the catalog, 
including the first one
+   * (optional). Retries apply to throttling and transient failures. The AWS 
clients used for
+   * Iceberg tables keep the AWS SDK default. Defaults to {@link 
#DEFAULT_AWS_GLUE_MAX_ATTEMPTS}.
+   */
+  public static final String AWS_GLUE_MAX_ATTEMPTS = "aws-glue-max-attempts";
+
+  /** Default value for {@link #AWS_GLUE_MAX_ATTEMPTS}. */
+  public static final int DEFAULT_AWS_GLUE_MAX_ATTEMPTS = 4;
+
   /**
    * Default table format used when creating tables via Gravitino's {@code 
createTable()} API
    * (optional). Accepted values: {@code iceberg}, {@code hive}. Defaults to 
{@code hive}.
diff --git 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalog.java
 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalog.java
index 6e2a163ca4..25711062a3 100644
--- 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalog.java
+++ 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalog.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.catalog.glue;
 
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import org.apache.commons.lang3.StringUtils;
@@ -45,6 +46,9 @@ public class GlueCatalog extends BaseCatalog<GlueCatalog> {
   static final GlueTablePropertiesMetadata TABLE_PROPERTIES_METADATA =
       new GlueTablePropertiesMetadata();
 
+  /** Plaintext catalog configuration, kept for the create-time checks. */
+  private Map<String, String> catalogConf = Collections.emptyMap();
+
   /**
    * Returns the short name of the Glue catalog.
    *
@@ -60,6 +64,30 @@ public class GlueCatalog extends BaseCatalog<GlueCatalog> {
     return false;
   }
 
+  @Override
+  public GlueCatalog withCatalogConf(Map<String, String> conf) {
+    this.catalogConf = conf == null ? Collections.emptyMap() : conf;
+    return super.withCatalogConf(conf);
+  }
+
+  /**
+   * Validates at create the configuration that is otherwise only checked when 
the Glue client is
+   * first built, so a misconfigured catalog is refused instead of stored.
+   */
+  @Override
+  public boolean shouldValidateOnCreate() {
+    return true;
+  }
+
+  /**
+   * Runs only local configuration checks. Creating a Glue catalog does not 
contact AWS, because
+   * credentials that only AWS can reject must not block the create.
+   */
+  @Override
+  public void validateOnCreate() {
+    GlueClientProvider.validateConfig(catalogConf);
+  }
+
   /**
    * Creates a new instance of {@link GlueCatalogOperations} with the provided 
configuration.
    *
diff --git 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
index 898e6964a2..4a9f5e621c 100644
--- 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
+++ 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
@@ -69,7 +69,6 @@ import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import software.amazon.awssdk.core.exception.SdkClientException;
 import software.amazon.awssdk.core.exception.SdkException;
 import software.amazon.awssdk.services.glue.GlueClient;
 import software.amazon.awssdk.services.glue.model.CreateDatabaseRequest;
@@ -165,7 +164,7 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
       applyCatalogId(catalogId, req::catalogId);
       glueClient.getDatabases(req.build());
     } catch (SdkException e) {
-      throw GlueExceptionConverter.toConnectionException(e);
+      throw GlueExceptionConverter.toConnectionException(e, glueClient);
     }
   }
 
@@ -325,6 +324,8 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
           throw new NonEmptySchemaException(
               "Schema %s is not empty. Use cascade=true to drop it with its 
tables.", ident.name());
         }
+      } catch (EntityNotFoundException e) {
+        return false;
       } catch (GlueException e) {
         throw GlueExceptionConverter.toSchemaException(
             e, "checking tables in schema " + ident.name());
@@ -380,6 +381,7 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
       software.amazon.awssdk.services.glue.model.Table rawGlueTable =
           callGlue(() -> glueClient.getTable(req.build()), "table " + 
ident.name()).table();
       rejectIfView(rawGlueTable, ident, dbName);
+      rejectIfFormatHidden(rawGlueTable, ident, dbName);
       GlueTable table = GlueTable.fromGlueTable(rawGlueTable, typeConverter);
 
       // Recover Iceberg-specific partitioning and sort orders from the 
Iceberg metadata.
@@ -420,6 +422,20 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
 
     String tableFormat = props.getOrDefault(GlueConstants.TABLE_FORMAT, 
defaultTableFormat);
     boolean isIceberg = 
GlueConstants.ICEBERG_TABLE_TYPE_VALUE.equalsIgnoreCase(tableFormat);
+    String normalizedFormat =
+        isIceberg
+            ? GlueConstants.ICEBERG_TABLE_TYPE_VALUE.toLowerCase(Locale.ROOT)
+            : props
+                .getOrDefault(GlueConstants.TABLE_FORMAT, 
GlueConstants.DEFAULT_TABLE_FORMAT_VALUE)
+                .toLowerCase(Locale.ROOT);
+    // A table of an excluded format would be created but stay invisible 
through this catalog.
+    Preconditions.checkArgument(
+        isFormatVisible(normalizedFormat),
+        "Cannot create table %s with format '%s': the catalog property '%s' 
only exposes %s",
+        ident.name(),
+        normalizedFormat,
+        GlueConstants.TABLE_FORMAT_FILTER,
+        tableFormatFilter);
 
     // Non-Iceberg (Hive) tables only support identity partitioning.
     // Iceberg tables delegate to GlueIcebergCatalogHelper which supports all 
transforms.
@@ -451,15 +467,19 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
       boolean registerMode = 
props.containsKey(GlueConstants.METADATA_LOCATION);
       if (!registerMode) {
         // Use Iceberg SDK to create the table (writes metadata.json to S3 and 
registers in Glue).
-        GlueIcebergTableHelper.createTable(
-            icebergGlueCatalog,
-            dbName,
-            ident.name(),
-            columns,
-            comment,
-            finalProps,
-            partitions,
-            sortOrders);
+        try {
+          GlueIcebergTableHelper.createTable(
+              icebergGlueCatalog,
+              dbName,
+              ident.name(),
+              columns,
+              comment,
+              finalProps,
+              partitions,
+              sortOrders);
+        } catch (RuntimeException e) {
+          throw GlueExceptionConverter.toIcebergException(e, "table " + 
ident.name());
+        }
         LOG.info("Created Iceberg table {}.{} via Iceberg SDK", dbName, 
ident.name());
         return loadTable(ident);
       }
@@ -524,6 +544,7 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
     }
 
     rejectIfView(rawGlueTable, ident, dbName);
+    rejectIfFormatHidden(rawGlueTable, ident, dbName);
 
     if (GlueIcebergTableHelper.isIcebergTable(rawGlueTable)) {
       return alterIcebergTable(ident, dbName, rawGlueTable, changes);
@@ -609,8 +630,13 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
     if (hasMetadataLocation && !isSdkManaged) {
       return alterRegisterModeIcebergTable(ident, dbName, rawGlueTable, 
changes);
     }
-    TableIdentifier finalId =
-        GlueIcebergTableHelper.alterTable(icebergGlueCatalog, dbName, 
ident.name(), changes);
+    TableIdentifier finalId;
+    try {
+      finalId =
+          GlueIcebergTableHelper.alterTable(icebergGlueCatalog, dbName, 
ident.name(), changes);
+    } catch (RuntimeException e) {
+      throw GlueExceptionConverter.toIcebergException(e, "altering table " + 
ident.name());
+    }
     String newDbName = finalId.namespace().level(0);
     NameIdentifier finalIdent;
     if (newDbName.equals(dbName) && finalId.name().equals(ident.name())) {
@@ -664,10 +690,12 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
         GetTableRequest.builder().databaseName(dbName).name(ident.name());
     applyCatalogId(catalogId, getReq::catalogId);
     try {
-      rejectIfView(
-          callGlue(() -> glueClient.getTable(getReq.build()), "table " + 
ident.name()).table(),
-          ident,
-          dbName);
+      Table existing =
+          callGlue(() -> glueClient.getTable(getReq.build()), "table " + 
ident.name()).table();
+      rejectIfView(existing, ident, dbName);
+      if (!matchesFormatFilter(existing)) {
+        return false;
+      }
     } catch (EntityNotFoundException e) {
       return false;
     } catch (GlueException e) {
@@ -725,7 +753,25 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
     }
     String normalized =
         fmt != null ? fmt.toLowerCase(Locale.ROOT) : 
GlueConstants.DEFAULT_TABLE_FORMAT_VALUE;
-    return tableFormatFilter.contains(normalized);
+    return isFormatVisible(normalized);
+  }
+
+  private boolean isFormatVisible(String normalizedFormat) {
+    return tableFormatFilter == null
+        || 
tableFormatFilter.contains(GlueConstants.DEFAULT_TABLE_FORMAT_FILTER)
+        || tableFormatFilter.contains(normalizedFormat);
+  }
+
+  /**
+   * Throws {@link NoSuchTableException} for a table whose format is excluded 
by {@code
+   * table-format-filter}, so that load and alter treat it as absent.
+   */
+  private void rejectIfFormatHidden(Table table, NameIdentifier ident, String 
dbName) {
+    if (!matchesFormatFilter(table)) {
+      throw new NoSuchTableException(
+          "Table %s.%s does not exist: its format is excluded by catalog 
property '%s'",
+          dbName, ident.name(), GlueConstants.TABLE_FORMAT_FILTER);
+    }
   }
 
   private void executeCreateTable(
@@ -975,28 +1021,18 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
   }
 
   /**
-   * Translates a raw AWS SDK credential-chain failure into a message naming 
this connector's own
-   * {@code aws-access-key-id} / {@code aws-secret-access-key} properties, so 
operators are not left
-   * to guess from the SDK's generic provider-chain error. Non-credential 
{@link
-   * SdkClientException}s (e.g. network failures) are rethrown unchanged.
-   */
-  private static RuntimeException 
translateCredentialFailure(SdkClientException e, String context) {
-    return GlueExceptionConverter.isCredentialFailure(e)
-        ? GlueExceptionConverter.toCredentialException(e, context)
-        : e;
-  }
-
-  /**
-   * Invokes a Glue SDK call, translating a credential-chain {@link 
SdkClientException} into an
-   * actionable error naming this connector's own credential properties. 
{@link GlueException} is
-   * left untouched so each call site's own catch block still applies its 
usual (e.g.
-   * not-found/already-exists) semantics.
+   * Invokes a Glue SDK call and converts the failures that do not depend on 
the object (credential,
+   * region, throttling, invalid input, access denied, timeout and transport 
failures) through
+   * {@link GlueExceptionConverter#toClientException}. Any other {@link 
SdkException} is rethrown
+   * unchanged so each call site's own catch block still applies its 
not-found/already-exists
+   * semantics.
    */
-  private static <T> T callGlue(Supplier<T> call, String context) {
+  private <T> T callGlue(Supplier<T> call, String context) {
     try {
       return call.get();
-    } catch (SdkClientException e) {
-      throw translateCredentialFailure(e, context);
+    } catch (SdkException e) {
+      RuntimeException converted = GlueExceptionConverter.toClientException(e, 
context, glueClient);
+      throw converted != null ? converted : e;
     }
   }
 
diff --git 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogPropertiesMetadata.java
 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogPropertiesMetadata.java
index 5e3f6ea92e..cc964daa32 100644
--- 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogPropertiesMetadata.java
@@ -19,86 +19,293 @@
 package org.apache.gravitino.catalog.glue;
 
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_ACCESS_KEY_ID;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_API_CALL_TIMEOUT_MS;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_CATALOG_ID;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_ENDPOINT;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_MAX_ATTEMPTS;
 import static org.apache.gravitino.catalog.glue.GlueConstants.AWS_REGION;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_SECRET_ACCESS_KEY;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.DEFAULT_AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.DEFAULT_AWS_GLUE_API_CALL_TIMEOUT_MS;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.DEFAULT_AWS_GLUE_MAX_ATTEMPTS;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.DEFAULT_TABLE_FORMAT;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.DEFAULT_TABLE_FORMAT_FILTER;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.DEFAULT_TABLE_FORMAT_VALUE;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.TABLE_FORMAT_FILTER;
 import static org.apache.gravitino.catalog.glue.GlueConstants.WAREHOUSE;
-import static 
org.apache.gravitino.connector.PropertyEntry.stringOptionalPropertyEntry;
-import static 
org.apache.gravitino.connector.PropertyEntry.stringRequiredPropertyEntry;
 
+import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.cloud.storage.AWSPropertiesMetadata;
 import org.apache.gravitino.connector.BaseCatalogPropertiesMetadata;
 import org.apache.gravitino.connector.PropertyEntry;
+import software.amazon.awssdk.regions.Region;
 
 /** Properties metadata for the AWS Glue Data Catalog connector catalog-level 
configuration. */
 public class GlueCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata {
 
+  /** Accepted values of {@code default-table-format}, compared 
case-insensitively. */
+  static final Set<String> DEFAULT_TABLE_FORMATS = ImmutableSet.of("iceberg", 
"hive");
+
+  /** Accepted entries of {@code table-format-filter}, compared 
case-insensitively. */
+  static final Set<String> TABLE_FORMAT_FILTER_VALUES =
+      ImmutableSet.of(DEFAULT_TABLE_FORMAT_FILTER, "hive", "iceberg", "delta", 
"parquet");
+
+  private static final Pattern CATALOG_ID_PATTERN = Pattern.compile("\\d{12}");
+
+  private static final Pattern HOST_PORT_PATTERN =
+      Pattern.compile("[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?(:\\d{1,5})?");
+
   private static final Map<String, PropertyEntry<?>> PROPERTIES_METADATA =
       ImmutableMap.<String, PropertyEntry<?>>builder()
           .put(
               AWS_REGION,
-              stringRequiredPropertyEntry(
+              stringEntry(
                   AWS_REGION,
                   "AWS region for the Glue Data Catalog (e.g. us-east-1)",
+                  true /* required */,
                   true /* immutable */,
-                  false /* hidden */))
+                  null /* defaultValue */,
+                  GlueCatalogPropertiesMetadata::checkRegion))
           .put(
               AWS_GLUE_CATALOG_ID,
-              stringOptionalPropertyEntry(
+              stringEntry(
                   AWS_GLUE_CATALOG_ID,
                   "The 12-digit AWS account ID that owns the Glue catalog."
                       + " When omitted, defaults to the caller's AWS account 
ID.",
+                  false /* required */,
                   true /* immutable */,
                   null /* defaultValue */,
-                  false /* hidden */))
+                  GlueCatalogPropertiesMetadata::checkCatalogId))
           .put(AWS_ACCESS_KEY_ID, AWSPropertiesMetadata.AWS_ACCESS_KEY_ID)
           .put(AWS_SECRET_ACCESS_KEY, 
AWSPropertiesMetadata.AWS_SECRET_ACCESS_KEY)
           .put(
               AWS_GLUE_ENDPOINT,
-              stringOptionalPropertyEntry(
+              stringEntry(
                   AWS_GLUE_ENDPOINT,
                   "Custom Glue endpoint URL for VPC endpoints or LocalStack 
testing"
                       + " (e.g. http://localhost:4566)",
+                  false /* required */,
                   false /* immutable */,
                   null /* defaultValue */,
-                  false /* hidden */))
+                  GlueCatalogPropertiesMetadata::checkEndpoint))
+          .put(
+              AWS_GLUE_API_CALL_TIMEOUT_MS,
+              positiveLongEntry(
+                  AWS_GLUE_API_CALL_TIMEOUT_MS,
+                  "Upper bound in milliseconds for one Glue API call made by 
the catalog,"
+                      + " including retries. Not applied to Iceberg table 
I/O.",
+                  DEFAULT_AWS_GLUE_API_CALL_TIMEOUT_MS))
+          .put(
+              AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS,
+              positiveLongEntry(
+                  AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS,
+                  "Upper bound in milliseconds for a single attempt of a Glue 
API call.",
+                  DEFAULT_AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS))
+          .put(
+              AWS_GLUE_MAX_ATTEMPTS,
+              new PropertyEntry.Builder<Integer>()
+                  .withName(AWS_GLUE_MAX_ATTEMPTS)
+                  .withDescription(
+                      "Maximum number of attempts of a Glue API call made by 
the catalog,"
+                          + " including the first one. Not applied to Iceberg 
table I/O.")
+                  .withJavaType(Integer.class)
+                  .withDefaultValue(DEFAULT_AWS_GLUE_MAX_ATTEMPTS)
+                  .withDecoder(
+                      value -> {
+                        int attempts = Integer.parseInt(value.trim());
+                        Preconditions.checkArgument(attempts > 0, "must be 
positive");
+                        return attempts;
+                      })
+                  .withEncoder(String::valueOf)
+                  .build())
           .put(
               DEFAULT_TABLE_FORMAT,
-              stringOptionalPropertyEntry(
+              stringEntry(
                   DEFAULT_TABLE_FORMAT,
                   "Default format for tables created via createTable(). 
Accepted: iceberg, hive.",
+                  false /* required */,
                   false /* immutable */,
                   DEFAULT_TABLE_FORMAT_VALUE,
-                  false /* hidden */))
+                  GlueCatalogPropertiesMetadata::checkDefaultTableFormat))
           .put(
               TABLE_FORMAT_FILTER,
-              stringOptionalPropertyEntry(
+              stringEntry(
                   TABLE_FORMAT_FILTER,
-                  "Comma-separated table formats exposed by listTables() and 
loadTable().",
+                  "Comma-separated table formats exposed by the catalog. 
Tables of other formats"
+                      + " are absent from list, load, alter and drop, and 
cannot be created."
+                      + " Accepted: all, hive, iceberg, delta, parquet.",
+                  false /* required */,
                   false /* immutable */,
                   DEFAULT_TABLE_FORMAT_FILTER,
-                  false /* hidden */))
+                  GlueCatalogPropertiesMetadata::checkTableFormatFilter))
           .put(
               WAREHOUSE,
-              stringRequiredPropertyEntry(
+              stringEntry(
                   WAREHOUSE,
                   "Base storage path used as warehouse when no explicit 
location is set"
                       + " at table creation time and the database declares no 
LocationUri"
                       + " (e.g. s3://my-bucket/warehouse)."
                       + " Table location is then derived as 
warehouse/database/table.",
+                  true /* required */,
                   false /* immutable */,
-                  false /* hidden */))
+                  null /* defaultValue */,
+                  GlueCatalogPropertiesMetadata::checkWarehouse))
           .build();
 
   @Override
   protected Map<String, PropertyEntry<?>> specificPropertyEntries() {
     return PROPERTIES_METADATA;
   }
+
+  /**
+   * Parses {@code aws-glue-endpoint} into a URI with an {@code http} or 
{@code https} scheme and a
+   * host.
+   *
+   * @param endpoint the configured endpoint
+   * @return the parsed endpoint URI
+   * @throws IllegalArgumentException if the endpoint is not such a URI
+   */
+  static URI parseEndpoint(String endpoint) {
+    URI uri;
+    try {
+      uri = new URI(endpoint);
+    } catch (URISyntaxException e) {
+      throw new IllegalArgumentException(
+          String.format("Property '%s' is not a valid URI: %s", 
AWS_GLUE_ENDPOINT, e.getMessage()),
+          e);
+    }
+    Preconditions.checkArgument(
+        ("http".equalsIgnoreCase(uri.getScheme()) || 
"https".equalsIgnoreCase(uri.getScheme()))
+            && hasHost(uri),
+        "Property '%s' must be an http or https URL with a host, got: %s",
+        AWS_GLUE_ENDPOINT,
+        endpoint);
+    return uri;
+  }
+
+  private static boolean hasHost(URI uri) {
+    // getHost() is null for host names with '_', such as Docker service 
names, so such an
+    // authority is accepted when it is a plain host name with an optional 
port.
+    return uri.getHost() != null
+        || (uri.getRawAuthority() != null
+            && HOST_PORT_PATTERN.matcher(uri.getRawAuthority()).matches());
+  }
+
+  private static String checkRegion(String region) {
+    // Region.of() accepts any string, and an unknown or mis-cased region only 
fails later as an
+    // unresolvable host or as a signature error that reads like rejected 
credentials.
+    Preconditions.checkArgument(
+        Region.regions().contains(Region.of(region)),
+        "Property '%s' must be a known AWS region identifier in lower case, 
got: '%s'",
+        AWS_REGION,
+        region);
+    return region;
+  }
+
+  private static String checkCatalogId(String catalogId) {
+    Preconditions.checkArgument(
+        CATALOG_ID_PATTERN.matcher(catalogId).matches(),
+        "Property '%s' must be a 12-digit AWS account ID, got: '%s'",
+        AWS_GLUE_CATALOG_ID,
+        catalogId);
+    return catalogId;
+  }
+
+  private static String checkEndpoint(String endpoint) {
+    parseEndpoint(endpoint);
+    return endpoint;
+  }
+
+  private static String checkWarehouse(String warehouse) {
+    URI uri;
+    try {
+      uri = new URI(warehouse);
+    } catch (URISyntaxException e) {
+      uri = null;
+    }
+    // A path without a scheme would become a location on the Gravitino 
server's local disk. The
+    // S3 requirement of Iceberg tables is checked when such a table is 
created, because Hive
+    // tables may live on other file systems.
+    Preconditions.checkArgument(
+        uri != null
+            && uri.getScheme() != null
+            && StringUtils.isNotBlank(uri.getRawSchemeSpecificPart())
+            && (!uri.getScheme().toLowerCase(Locale.ROOT).startsWith("s3")
+                || GlueIcebergTableHelper.isS3Location(warehouse)),
+        "Property '%s' must be a URI with a scheme, such as s3://bucket/path, 
got: '%s'",
+        WAREHOUSE,
+        warehouse);
+    return warehouse;
+  }
+
+  private static String checkDefaultTableFormat(String format) {
+    Preconditions.checkArgument(
+        DEFAULT_TABLE_FORMATS.contains(format.toLowerCase(Locale.ROOT)),
+        "Property '%s' must be one of %s, got: '%s'",
+        DEFAULT_TABLE_FORMAT,
+        DEFAULT_TABLE_FORMATS,
+        format);
+    return format;
+  }
+
+  private static String checkTableFormatFilter(String filter) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(filter)
+            && Arrays.stream(filter.split(",", -1))
+                .map(s -> s.trim().toLowerCase(Locale.ROOT))
+                .allMatch(TABLE_FORMAT_FILTER_VALUES::contains),
+        "Property '%s' must be a comma-separated list of %s, got: '%s'",
+        TABLE_FORMAT_FILTER,
+        TABLE_FORMAT_FILTER_VALUES,
+        filter);
+    return filter;
+  }
+
+  private static PropertyEntry<String> stringEntry(
+      String name,
+      String description,
+      boolean required,
+      boolean immutable,
+      String defaultValue,
+      Function<String, String> decoder) {
+    return new PropertyEntry.Builder<String>()
+        .withName(name)
+        .withDescription(description)
+        .withRequired(required)
+        .withImmutable(immutable)
+        .withJavaType(String.class)
+        .withDefaultValue(defaultValue)
+        .withDecoder(decoder)
+        .withEncoder(Function.identity())
+        .build();
+  }
+
+  private static PropertyEntry<Long> positiveLongEntry(
+      String name, String description, long defaultValue) {
+    return new PropertyEntry.Builder<Long>()
+        .withName(name)
+        .withDescription(description)
+        .withJavaType(Long.class)
+        .withDefaultValue(defaultValue)
+        .withDecoder(
+            value -> {
+              long parsed = Long.parseLong(value.trim());
+              Preconditions.checkArgument(parsed > 0, "must be positive");
+              return parsed;
+            })
+        .withEncoder(String::valueOf)
+        .build();
+  }
 }
diff --git 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
index be0dbe1925..b25e6b14c8 100644
--- 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
+++ 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
@@ -21,17 +21,21 @@ package org.apache.gravitino.catalog.glue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import java.net.URI;
+import java.time.Duration;
 import java.util.Map;
+import java.util.Optional;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.exceptions.ConnectionFailedException;
 import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
 import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
 import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
 import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
 import software.amazon.awssdk.core.exception.SdkClientException;
 import software.amazon.awssdk.regions.Region;
 import software.amazon.awssdk.services.glue.GlueClient;
 import software.amazon.awssdk.services.glue.GlueClientBuilder;
+import software.amazon.awssdk.services.glue.GlueServiceClientConfiguration;
 
 /**
  * Factory for creating AWS {@link GlueClient} instances from Gravitino 
catalog configuration.
@@ -48,6 +52,8 @@ import software.amazon.awssdk.services.glue.GlueClientBuilder;
  */
 public final class GlueClientProvider {
 
+  private static final String TEMPORARY_ACCESS_KEY_PREFIX = "ASIA";
+
   private GlueClientProvider() {}
 
   /**
@@ -55,22 +61,16 @@ public final class GlueClientProvider {
    *
    * @param config Catalog configuration properties.
    * @return A configured and ready-to-use {@link GlueClient}.
-   * @throws IllegalArgumentException if {@code aws-region} is missing or 
blank, if only one of the
-   *     credential keys is provided, or if {@code aws-glue-endpoint} is not a 
valid URI
+   * @throws IllegalArgumentException if the configuration fails {@link 
#validateConfig(Map)}
    * @throws ConnectionFailedException if the configured credential provider 
cannot resolve
    *     credentials
    */
   public static GlueClient buildClient(Map<String, String> config) {
+    validateConfig(config);
     String region = config.get(GlueConstants.AWS_REGION);
-    Preconditions.checkArgument(
-        StringUtils.isNotBlank(region),
-        "Property '%s' is required to create a Glue client",
-        GlueConstants.AWS_REGION);
-
     GlueClientBuilder builder = GlueClient.builder().region(Region.of(region));
 
     // Static credentials take priority over the default credential chain.
-    // Both keys must be provided together — a partial pair is always a 
misconfiguration.
     // Default credential chain order (when both keys are omitted):
     //   1. Java system properties (aws.accessKeyId / aws.secretAccessKey)
     //   2. Environment variables (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)
@@ -80,24 +80,118 @@ public final class GlueClientProvider {
     //   6. EC2 instance profile (IMDSv2)
     String accessKey = config.get(GlueConstants.AWS_ACCESS_KEY_ID);
     String secretKey = config.get(GlueConstants.AWS_SECRET_ACCESS_KEY);
-    boolean hasStaticCredentials = hasAwsStaticCredentials(accessKey, 
secretKey);
-
     AwsCredentialsProvider credentialsProvider =
-        hasStaticCredentials
+        hasAwsStaticCredentials(accessKey, secretKey)
             ? 
StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, 
secretKey))
             : DefaultCredentialsProvider.builder().build();
     validateCredentials(credentialsProvider);
     builder.credentialsProvider(credentialsProvider);
 
+    // Bound every call so an endpoint that does not answer cannot hold a 
request thread for the
+    // SDK default (connect timeout x resolved addresses x attempts).
+    int maxAttempts =
+        intProperty(
+            config,
+            GlueConstants.AWS_GLUE_MAX_ATTEMPTS,
+            GlueConstants.DEFAULT_AWS_GLUE_MAX_ATTEMPTS);
+    builder.overrideConfiguration(
+        ClientOverrideConfiguration.builder()
+            .apiCallTimeout(Duration.ofMillis(apiCallTimeoutMs(config)))
+            
.apiCallAttemptTimeout(Duration.ofMillis(apiCallAttemptTimeoutMs(config)))
+            .retryStrategy(b -> b.maxAttempts(maxAttempts))
+            .build());
+
     // Optional custom endpoint override for VPC endpoints or LocalStack 
testing.
     String endpoint = config.get(GlueConstants.AWS_GLUE_ENDPOINT);
     if (StringUtils.isNotBlank(endpoint)) {
-      builder.endpointOverride(URI.create(endpoint));
+      
builder.endpointOverride(GlueCatalogPropertiesMetadata.parseEndpoint(endpoint));
     }
 
     return builder.build();
   }
 
+  /**
+   * Runs the local configuration checks that need no AWS call: {@code 
aws-region} is set, the
+   * static key pair is complete and is a long-term key, and {@code 
aws-glue-endpoint} is a valid
+   * URL.
+   *
+   * @param config Catalog configuration properties.
+   * @throws IllegalArgumentException if any check fails
+   */
+  public static void validateConfig(Map<String, String> config) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(config.get(GlueConstants.AWS_REGION)),
+        "Property '%s' is required to create a Glue client",
+        GlueConstants.AWS_REGION);
+
+    String accessKey = config.get(GlueConstants.AWS_ACCESS_KEY_ID);
+    if (hasAwsStaticCredentials(accessKey, 
config.get(GlueConstants.AWS_SECRET_ACCESS_KEY))) {
+      // Temporary keys (ASIA...) are only valid together with a session 
token, which this
+      // connector cannot be given, so AWS would reject every request signed 
with them.
+      Preconditions.checkArgument(
+          !accessKey.trim().startsWith(TEMPORARY_ACCESS_KEY_PREFIX),
+          "Property '%s' holds a temporary AWS access key (prefix %s). 
Temporary credentials need"
+              + " a session token and are not supported. Use a long-term key 
pair, or omit both"
+              + " '%s' and '%s' to use the default AWS credential chain.",
+          GlueConstants.AWS_ACCESS_KEY_ID,
+          TEMPORARY_ACCESS_KEY_PREFIX,
+          GlueConstants.AWS_ACCESS_KEY_ID,
+          GlueConstants.AWS_SECRET_ACCESS_KEY);
+    }
+
+    String endpoint = config.get(GlueConstants.AWS_GLUE_ENDPOINT);
+    if (StringUtils.isNotBlank(endpoint)) {
+      GlueCatalogPropertiesMetadata.parseEndpoint(endpoint);
+    }
+  }
+
+  /**
+   * Returns the configured upper bound in milliseconds for one Glue API call.
+   *
+   * @param config Catalog configuration properties.
+   * @return the total call timeout in milliseconds
+   */
+  static long apiCallTimeoutMs(Map<String, String> config) {
+    return longProperty(
+        config,
+        GlueConstants.AWS_GLUE_API_CALL_TIMEOUT_MS,
+        GlueConstants.DEFAULT_AWS_GLUE_API_CALL_TIMEOUT_MS);
+  }
+
+  /**
+   * Returns the configured upper bound in milliseconds for one attempt of a 
Glue API call.
+   *
+   * @param config Catalog configuration properties.
+   * @return the per-attempt timeout in milliseconds
+   */
+  static long apiCallAttemptTimeoutMs(Map<String, String> config) {
+    return longProperty(
+        config,
+        GlueConstants.AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS,
+        GlueConstants.DEFAULT_AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS);
+  }
+
+  /**
+   * Describes the Glue endpoint a client calls, for error messages: the 
endpoint override when one
+   * is configured, otherwise the region whose default endpoint is used.
+   *
+   * @param client the Glue client, may be null
+   * @return a human-readable endpoint description
+   */
+  static String describeEndpoint(GlueClient client) {
+    GlueServiceClientConfiguration conf =
+        client == null ? null : client.serviceClientConfiguration();
+    if (conf == null) {
+      return "the configured endpoint";
+    }
+    Optional<URI> override = conf.endpointOverride();
+    if (override != null && override.isPresent()) {
+      return String.format("'%s' (%s)", override.get(), 
GlueConstants.AWS_GLUE_ENDPOINT);
+    }
+    return String.format(
+        "the default endpoint of region '%s' (%s)", conf.region(), 
GlueConstants.AWS_REGION);
+  }
+
   /**
    * Eagerly resolves {@code credentialsProvider} when Glue operations are 
initialized, instead of
    * leaving resolution to the first real Glue API call. This makes an 
explicit connection test or
@@ -128,4 +222,14 @@ public final class GlueClientProvider {
 
     return hasAccessKey;
   }
+
+  private static long longProperty(Map<String, String> config, String key, 
long defaultValue) {
+    String value = config.get(key);
+    return StringUtils.isBlank(value) ? defaultValue : 
Long.parseLong(value.trim());
+  }
+
+  private static int intProperty(Map<String, String> config, String key, int 
defaultValue) {
+    String value = config.get(key);
+    return StringUtils.isBlank(value) ? defaultValue : 
Integer.parseInt(value.trim());
+  }
 }
diff --git 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueExceptionConverter.java
 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueExceptionConverter.java
index 5725c75fb1..bf3dbc7089 100644
--- 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueExceptionConverter.java
+++ 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueExceptionConverter.java
@@ -19,22 +19,37 @@
 package org.apache.gravitino.catalog.glue;
 
 import java.util.Set;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.exceptions.ConnectionFailedException;
 import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
+import org.apache.gravitino.exceptions.ServiceUnavailableException;
 import org.apache.gravitino.exceptions.TableAlreadyExistsException;
 import org.apache.gravitino.utils.ExceptionMessages;
+import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.exceptions.CommitStateUnknownException;
+import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
+import software.amazon.awssdk.core.exception.ApiCallAttemptTimeoutException;
+import software.amazon.awssdk.core.exception.ApiCallTimeoutException;
 import software.amazon.awssdk.core.exception.SdkClientException;
 import software.amazon.awssdk.core.exception.SdkException;
+import software.amazon.awssdk.services.glue.GlueClient;
+import software.amazon.awssdk.services.glue.GlueServiceClientConfiguration;
 import software.amazon.awssdk.services.glue.model.AccessDeniedException;
 import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
+import 
software.amazon.awssdk.services.glue.model.ConcurrentModificationException;
 import software.amazon.awssdk.services.glue.model.EntityNotFoundException;
 import software.amazon.awssdk.services.glue.model.GlueException;
 import software.amazon.awssdk.services.glue.model.InvalidInputException;
+import software.amazon.awssdk.services.glue.model.ThrottlingException;
+import software.amazon.awssdk.services.glue.model.ValidationException;
 
 /** Converts AWS Glue SDK exceptions to Gravitino exceptions. */
 final class GlueExceptionConverter {
@@ -42,6 +57,15 @@ final class GlueExceptionConverter {
   private static final String NO_CREDENTIALS_MARKER =
       "Unable to load credentials from any of the providers";
 
+  /** Markers AWS puts in a signature error when the request was signed for 
the wrong region. */
+  private static final Set<String> REGION_SCOPE_MARKERS =
+      Set.of(
+          "Credential should be scoped to a valid region",
+          "Credential should be scoped to correct region");
+
+  /** AWS messages for invalid input can quote whole request values; longer 
ones are shortened. */
+  private static final int MAX_AWS_MESSAGE_LENGTH = 1024;
+
   private static final Set<String> AUTHENTICATION_ERROR_CODES =
       Set.of(
           "AuthFailure",
@@ -77,7 +101,7 @@ final class GlueExceptionConverter {
    *
    * @param e the credential-resolution failure
    * @param context description of the operation context for error messages
-   * @return a Gravitino runtime exception with an actionable message
+   * @return a runtime exception with an actionable message
    */
   static RuntimeException toCredentialException(SdkClientException e, String 
context) {
     return new RuntimeException(
@@ -101,19 +125,46 @@ final class GlueExceptionConverter {
     AwsErrorDetails details = e.awsErrorDetails();
     return details != null
         && StringUtils.isNotBlank(details.errorCode())
-        && AUTHENTICATION_ERROR_CODES.contains(details.errorCode());
+        && AUTHENTICATION_ERROR_CODES.contains(details.errorCode())
+        && !isRegionScopeFailure(e);
   }
 
   /**
-   * Converts an AWS Glue SDK failure raised by a connection probe into a 
connection error. Known
-   * credential failures name the connector properties that an operator can 
correct; authorization
-   * and transport failures retain the AWS or SDK detail without claiming the 
credentials are
-   * invalid.
+   * Whether AWS refused the request signature because it was scoped to a 
region other than the one
+   * of the endpoint. The credentials themselves are valid in that case.
+   *
+   * @param e the service exception raised by AWS Glue
+   * @return true if the signature was scoped to the wrong region
+   */
+  static boolean isRegionScopeFailure(GlueException e) {
+    AwsErrorDetails details = e.awsErrorDetails();
+    String message = details != null ? details.errorMessage() : null;
+    String text = StringUtils.defaultIfBlank(message, e.getMessage());
+    return text != null && 
REGION_SCOPE_MARKERS.stream().anyMatch(text::contains);
+  }
+
+  /**
+   * Converts an AWS Glue SDK failure raised by a connection probe into a 
connection error.
    *
    * @param e the SDK failure raised by the connection probe
    * @return an actionable connection failure
    */
   static ConnectionFailedException toConnectionException(SdkException e) {
+    return toConnectionException(e, null);
+  }
+
+  /**
+   * Converts an AWS Glue SDK failure raised by a connection probe into a 
connection error. Known
+   * credential and region failures name the connector properties that an 
operator can correct;
+   * authorization and transport failures retain the AWS or SDK detail without 
claiming the
+   * credentials are invalid.
+   *
+   * @param e the SDK failure raised by the connection probe
+   * @param client the Glue client that made the call, used to name its region 
and endpoint
+   * @return an actionable connection failure
+   */
+  static ConnectionFailedException toConnectionException(
+      SdkException e, @Nullable GlueClient client) {
     if (e instanceof SdkClientException && 
isCredentialFailure((SdkClientException) e)) {
       return new ConnectionFailedException(
           e,
@@ -123,6 +174,10 @@ final class GlueExceptionConverter {
           GlueConstants.AWS_ACCESS_KEY_ID,
           GlueConstants.AWS_SECRET_ACCESS_KEY);
     }
+    if (e instanceof GlueException && isRegionScopeFailure((GlueException) e)) 
{
+      return new ConnectionFailedException(
+          e, "%s", regionMismatchMessage((GlueException) e, client));
+    }
     if (e instanceof GlueException && isAuthenticationFailure((GlueException) 
e)) {
       return new ConnectionFailedException(
           e,
@@ -133,11 +188,111 @@ final class GlueExceptionConverter {
           awsErrorDetail((GlueException) e));
     }
 
+    String detail =
+        e instanceof GlueException
+            ? awsErrorDetail((GlueException) e)
+            : StringUtils.defaultIfBlank(
+                ExceptionMessages.usefulMessage(e), 
e.getClass().getSimpleName());
+    return new ConnectionFailedException(
+        e,
+        "Failed to connect to AWS Glue at %s: %s",
+        GlueClientProvider.describeEndpoint(client),
+        detail);
+  }
+
+  /**
+   * Converts the failures that do not depend on the object being operated on: 
missing, rejected or
+   * mis-scoped credentials, throttling, invalid input, access denial, and any 
other client-side SDK
+   * failure (timeouts, transport errors), which is reported as a connection 
failure.
+   *
+   * @param e the SDK failure
+   * @param context description of the operation context for error messages
+   * @param client the Glue client that made the call, used to name its region 
and endpoint; may be
+   *     null
+   * @return the converted exception, or null when the failure is specific to 
the object (for
+   *     example not found or already exists) and must be converted by the 
caller
+   */
+  @Nullable
+  static RuntimeException toClientException(
+      SdkException e, String context, @Nullable GlueClient client) {
+    if (e instanceof SdkClientException) {
+      SdkClientException clientException = (SdkClientException) e;
+      if (isCredentialFailure(clientException)) {
+        return toCredentialException(clientException, context);
+      }
+      String endpoint = GlueClientProvider.describeEndpoint(client);
+      String detail =
+          StringUtils.defaultIfBlank(
+              ExceptionMessages.usefulMessage(e), 
e.getClass().getSimpleName());
+      if (e instanceof ApiCallTimeoutException || e instanceof 
ApiCallAttemptTimeoutException) {
+        return new ConnectionFailedException(
+            e, "AWS Glue at %s timed out for %s: %s", endpoint, context, 
detail);
+      }
+      return new ConnectionFailedException(
+          e, "Failed to reach AWS Glue at %s for %s: %s", endpoint, context, 
detail);
+    }
+    if (!(e instanceof GlueException)) {
+      return null;
+    }
+
+    GlueException glueException = (GlueException) e;
+    if (isRegionScopeFailure(glueException)) {
+      return new IllegalArgumentException(
+          String.format(
+              "Failed to call AWS Glue for %s. %s",
+              context, regionMismatchMessage(glueException, client)),
+          e);
+    }
+    if (isAuthenticationFailure(glueException)) {
+      return new ForbiddenException(
+          e,
+          "Failed to authenticate with AWS Glue for %s. AWS rejected the 
configured "
+              + "credentials. Verify the '%s' and '%s' catalog properties, or 
the configured "
+              + "default AWS credential source. AWS error: %s",
+          context,
+          GlueConstants.AWS_ACCESS_KEY_ID,
+          GlueConstants.AWS_SECRET_ACCESS_KEY,
+          awsErrorDetail(glueException));
+    }
+    if (e instanceof ThrottlingException || 
glueException.isThrottlingException()) {
+      return new ServiceUnavailableException(
+          e,
+          "AWS Glue throttled the request for %s; the request can be retried. 
AWS error: %s",
+          context,
+          awsErrorDetail(glueException));
+    }
+    if (e instanceof InvalidInputException || e instanceof 
ValidationException) {
+      return new IllegalArgumentException(
+          String.format(
+              "AWS Glue rejected the input for %s: %s", context, 
awsErrorDetail(glueException)),
+          e);
+    }
+    if (e instanceof AccessDeniedException) {
+      return new ForbiddenException(
+          e, "Glue error: %s: %s", context, awsErrorDetail(glueException));
+    }
+    return null;
+  }
+
+  /**
+   * Converts an AWS Glue SDK failure that has no object-specific meaning to 
the caller.
+   *
+   * @param e the SDK failure
+   * @param context description of the operation context for error messages
+   * @param client the Glue client that made the call; may be null
+   * @return a Gravitino or standard Java runtime exception
+   */
+  static RuntimeException toGenericException(
+      SdkException e, String context, @Nullable GlueClient client) {
+    RuntimeException converted = toClientException(e, context, client);
+    if (converted != null) {
+      return converted;
+    }
     String detail =
         e instanceof GlueException
             ? awsErrorDetail((GlueException) e)
             : StringUtils.defaultIfBlank(e.getMessage(), 
e.getClass().getSimpleName());
-    return new ConnectionFailedException(e, "Failed to connect to AWS Glue: 
%s", detail);
+    return new RuntimeException("Glue error: " + context + ": " + detail, e);
   }
 
   /**
@@ -148,22 +303,13 @@ final class GlueExceptionConverter {
    * @return a Gravitino or standard Java runtime exception
    */
   static RuntimeException toSchemaException(GlueException e, String context) {
-    if (isAuthenticationFailure(e)) {
-      return toAuthenticationException(e, context);
-    }
     if (e instanceof EntityNotFoundException) {
       return new NoSuchSchemaException(e, "%s does not exist", context);
     }
     if (e instanceof AlreadyExistsException) {
       return new SchemaAlreadyExistsException(e, "%s already exists", context);
     }
-    if (e instanceof InvalidInputException) {
-      return ExceptionMessages.illegalArgument(context, e);
-    }
-    if (e instanceof AccessDeniedException) {
-      return new ForbiddenException(e, "Glue error: %s: %s", context, 
awsErrorDetail(e));
-    }
-    return new RuntimeException("Glue error: " + context + ": " + 
awsErrorDetail(e), e);
+    return toGenericException(e, context, null);
   }
 
   /**
@@ -174,46 +320,150 @@ final class GlueExceptionConverter {
    * @return a Gravitino or standard Java runtime exception
    */
   static RuntimeException toTableException(GlueException e, String context) {
-    if (isAuthenticationFailure(e)) {
-      return toAuthenticationException(e, context);
-    }
     if (e instanceof EntityNotFoundException) {
       return new NoSuchTableException(e, "%s does not exist", context);
     }
     if (e instanceof AlreadyExistsException) {
       return new TableAlreadyExistsException(e, "%s already exists", context);
     }
-    if (e instanceof InvalidInputException) {
-      return ExceptionMessages.illegalArgument(context, e);
+    return toGenericException(e, context, null);
+  }
+
+  /**
+   * Converts a failure raised by the Iceberg SDK into a Gravitino exception. 
Iceberg reports Glue
+   * and S3 errors either as its own exception types or wrapped around the AWS 
SDK exception, so the
+   * cause chain is walked from the outside in and the first recognized type 
decides the result:
+   * Iceberg not-found, already-exists, validation and forbidden errors map to 
the matching
+   * Gravitino types, a commit conflict maps to {@link 
OptimisticLockException}, and AWS SDK causes
+   * go through {@link #toGenericException}. A commit whose state is unknown 
is reported as such,
+   * because the change may have been applied. {@link 
GravitinoRuntimeException}, {@link
+   * IllegalArgumentException} and {@link UnsupportedOperationException} are 
returned as is.
+   *
+   * @param e the failure raised by an Iceberg catalog call
+   * @param context description of the operation context for error messages
+   * @return a Gravitino or standard Java runtime exception
+   */
+  static RuntimeException toIcebergException(RuntimeException e, String 
context) {
+    if (e instanceof GravitinoRuntimeException
+        || e instanceof IllegalArgumentException
+        || e instanceof UnsupportedOperationException) {
+      return e;
     }
-    if (e instanceof AccessDeniedException) {
-      return new ForbiddenException(e, "Glue error: %s: %s", context, 
awsErrorDetail(e));
+    for (Throwable t = e; t != null; t = t.getCause() == t ? null : 
t.getCause()) {
+      if (t instanceof CommitStateUnknownException) {
+        // A local validation failure underneath means nothing was sent, so 
the commit did not
+        // happen. Any other cause, such as a transport failure, does not mean 
the commit failed.
+        Throwable invalid = findCause(t, 
org.apache.iceberg.exceptions.ValidationException.class);
+        if (invalid != null) {
+          return new IllegalArgumentException(
+              String.format("Invalid request for %s: %s", context, 
describe(invalid)), e);
+        }
+        return new RuntimeException(
+            String.format(
+                "%s: the commit state is unknown and the change may have been 
applied; check the"
+                    + " table before retrying: %s",
+                context, describe(t.getCause() != null ? t.getCause() : t)),
+            e);
+      }
+      if (t instanceof CommitFailedException) {
+        Throwable awsCause = findCause(t, SdkException.class);
+        return awsCause == null || awsCause instanceof 
ConcurrentModificationException
+            ? new OptimisticLockException(
+                e, "%s: the table was changed concurrently, retry: %s", 
context, describe(t))
+            : toGenericException((SdkException) awsCause, context, null);
+      }
+      if (t instanceof AlreadyExistsException) {
+        return new TableAlreadyExistsException(
+            e, "%s: %s", context, awsErrorDetail((AlreadyExistsException) t));
+      }
+      if (t instanceof org.apache.iceberg.exceptions.AlreadyExistsException) {
+        return new TableAlreadyExistsException(e, "%s: %s", context, 
describe(t));
+      }
+      if (t instanceof NoSuchNamespaceException) {
+        return new NoSuchSchemaException(e, "%s: %s", context, describe(t));
+      }
+      if (t instanceof org.apache.iceberg.exceptions.NoSuchTableException) {
+        return new NoSuchTableException(e, "%s: %s", context, describe(t));
+      }
+      if (t instanceof org.apache.iceberg.exceptions.NotFoundException) {
+        // Iceberg wraps a Glue EntityNotFoundException here; it may be the 
table or its database.
+        return new NotFoundException(e, "%s: %s", context, describe(t));
+      }
+      if (t instanceof org.apache.iceberg.exceptions.ValidationException) {
+        return new IllegalArgumentException(
+            String.format("Invalid request for %s: %s", context, describe(t)), 
e);
+      }
+      if (t instanceof org.apache.iceberg.exceptions.ForbiddenException) {
+        // A Glue denial deeper in the chain keeps its AWS error code and 
message through the
+        // shared mapping; a denial by another service (such as S3) stays 
forbidden.
+        Throwable awsCause = findCause(t, SdkException.class);
+        return awsCause instanceof GlueException
+            ? toGenericException((SdkException) awsCause, context, null)
+            : new ForbiddenException(
+                e, "%s: %s", context, describe(awsCause != null ? awsCause : 
t));
+      }
+      if (t instanceof EntityNotFoundException) {
+        // Not wrapped by Iceberg; it may be the table or its database.
+        return new NotFoundException(e, "%s: %s", context, describe(t));
+      }
+      if (t instanceof SdkException) {
+        return toGenericException((SdkException) t, context, null);
+      }
     }
-    return new RuntimeException("Glue error: " + context + ": " + 
awsErrorDetail(e), e);
+    return new RuntimeException(context + ": " + describe(e), e);
   }
 
-  private static RuntimeException toAuthenticationException(GlueException e, 
String context) {
-    return new RuntimeException(
-        String.format(
-            "Failed to authenticate with AWS Glue for %s. AWS rejected the 
configured "
-                + "credentials. Verify the '%s' and '%s' catalog properties, 
or the configured "
-                + "default AWS credential source. AWS error: %s",
-            context,
-            GlueConstants.AWS_ACCESS_KEY_ID,
-            GlueConstants.AWS_SECRET_ACCESS_KEY,
-            awsErrorDetail(e)),
-        e);
+  private static String regionMismatchMessage(GlueException e, @Nullable 
GlueClient client) {
+    GlueServiceClientConfiguration conf =
+        client == null ? null : client.serviceClientConfiguration();
+    String region = conf != null && conf.region() != null ? conf.region().id() 
: "unknown";
+    String endpoint =
+        conf != null && conf.endpointOverride() != null && 
conf.endpointOverride().isPresent()
+            ? conf.endpointOverride().get().toString()
+            : "(not set)";
+    return String.format(
+        "The request was signed for a region that does not match the AWS Glue 
endpoint. Check that"
+            + " '%s' (%s) and '%s' (%s) refer to the same region. AWS error: 
%s",
+        GlueConstants.AWS_REGION,
+        region,
+        GlueConstants.AWS_GLUE_ENDPOINT,
+        endpoint,
+        awsErrorDetail(e));
+  }
+
+  @Nullable
+  private static Throwable findCause(Throwable t, Class<?> type) {
+    for (Throwable c = t.getCause(); c != null; c = c.getCause() == c ? null : 
c.getCause()) {
+      if (type.isInstance(c)) {
+        return c;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Describes a throwable for an error message without the name of the 
internal Iceberg catalog,
+   * which means nothing to the caller, falling back to the type name when 
there is no message.
+   */
+  private static String describe(Throwable t) {
+    String message = StringUtils.defaultIfBlank(t.getMessage(), 
t.getClass().getSimpleName());
+    return message.replace(GlueIcebergTableHelper.ICEBERG_CATALOG_NAME + ".", 
"");
   }
 
   /**
    * Renders the AWS-side detail of a Glue exception. AWS names the failing 
action and the resource
    * there, which is what the caller needs to act on; the error code is 
prefixed so the failure can
-   * be classified at a glance.
+   * be classified at a glance. Long messages, which quote the offending 
request value, are
+   * shortened in the middle so the violated constraint at the end stays 
visible.
    *
    * @param e the Glue exception to describe
    * @return the AWS error code and message, or a best-effort description when 
they are unavailable
    */
   private static String awsErrorDetail(GlueException e) {
+    return StringUtils.abbreviateMiddle(rawAwsErrorDetail(e), " ... ", 
MAX_AWS_MESSAGE_LENGTH);
+  }
+
+  private static String rawAwsErrorDetail(GlueException e) {
     AwsErrorDetails details = e.awsErrorDetails();
     if (details != null) {
       String code = details.errorCode();
diff --git 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueIcebergTableHelper.java
 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueIcebergTableHelper.java
index 9d9d59d4af..17deff4297 100644
--- 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueIcebergTableHelper.java
+++ 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueIcebergTableHelper.java
@@ -42,6 +42,9 @@ import static 
org.apache.gravitino.rel.types.Types.VarCharType;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSet;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -94,15 +97,25 @@ import org.slf4j.LoggerFactory;
  */
 final class GlueIcebergTableHelper {
 
+  /** Name of the internal Iceberg catalog; it never reaches users. */
+  static final String ICEBERG_CATALOG_NAME = "gravitino-glue-iceberg";
+
   private static final Logger LOG = 
LoggerFactory.getLogger(GlueIcebergTableHelper.class);
 
   private static final String DOT = ".";
 
+  private static final Set<String> S3_SCHEMES = ImmutableSet.of("s3", "s3a", 
"s3n");
+
   // Iceberg GlueCatalog properties (not defined in IcebergConstants).
   private static final String CATALOG_IMPL = "catalog-impl";
   private static final String GLUE_CATALOG = "GlueCatalog";
   private static final String GLUE_ID = "glue.id";
   private static final String GLUE_ENDPOINT = "glue.endpoint";
+  private static final String HTTP_CLIENT_TYPE = "http-client.type";
+  private static final String HTTP_CLIENT_APACHE_CONNECTION_TIMEOUT_MS =
+      "http-client.apache.connection-timeout-ms";
+  private static final String HTTP_CLIENT_APACHE_SOCKET_TIMEOUT_MS =
+      "http-client.apache.socket-timeout-ms";
   private static final String CLIENT_CREDENTIALS_PROVIDER = 
"client.credentials-provider";
   private static final String CLIENT_CREDENTIALS_PROVIDER_ACCESS_KEY_ID =
       "client.credentials-provider.access-key-id";
@@ -146,11 +159,29 @@ final class GlueIcebergTableHelper {
     Map<String, String> icebergProps = buildIcebergCatalogProperties(config);
 
     GlueCatalog glueCatalog = new GlueCatalog();
-    glueCatalog.initialize("gravitino-glue-iceberg", icebergProps);
+    glueCatalog.initialize(ICEBERG_CATALOG_NAME, icebergProps);
     LOG.info("Initialized Iceberg GlueCatalog for region {}", 
config.get(GlueConstants.AWS_REGION));
     return glueCatalog;
   }
 
+  /**
+   * Whether {@code location} is an S3 URI with a bucket, the only kind of 
location S3FileIO can
+   * write Iceberg metadata to.
+   *
+   * @param location the table location
+   * @return true for an {@code s3://}, {@code s3a://} or {@code s3n://} URI 
with a bucket
+   */
+  static boolean isS3Location(String location) {
+    try {
+      URI uri = new URI(location);
+      return uri.getScheme() != null
+          && S3_SCHEMES.contains(uri.getScheme().toLowerCase(Locale.ROOT))
+          && StringUtils.isNotBlank(uri.getRawAuthority());
+    } catch (URISyntaxException e) {
+      return false;
+    }
+  }
+
   static Map<String, String> buildIcebergCatalogProperties(Map<String, String> 
config) {
     String region = config.get(GlueConstants.AWS_REGION);
     Preconditions.checkArgument(region != null, "AWS region is required for 
Iceberg Glue catalog");
@@ -188,6 +219,13 @@ final class GlueIcebergTableHelper {
       icebergProps.put(GLUE_ENDPOINT, endpoint);
     }
 
+    // The Iceberg AWS clients take no total call timeout, so bound each 
connection and read by
+    // the per-attempt timeout of the catalog's own Glue client.
+    String attemptTimeoutMs = 
String.valueOf(GlueClientProvider.apiCallAttemptTimeoutMs(config));
+    icebergProps.put(HTTP_CLIENT_TYPE, "apache");
+    icebergProps.put(HTTP_CLIENT_APACHE_CONNECTION_TIMEOUT_MS, 
attemptTimeoutMs);
+    icebergProps.put(HTTP_CLIENT_APACHE_SOCKET_TIMEOUT_MS, attemptTimeoutMs);
+
     icebergProps.put(IcebergConstants.IO_IMPL, 
"org.apache.iceberg.aws.s3.S3FileIO");
     return icebergProps;
   }
@@ -349,6 +387,14 @@ final class GlueIcebergTableHelper {
     String location = properties.get(GlueConstants.LOCATION);
     Preconditions.checkArgument(
         location != null, "Location is required for Iceberg table creation");
+    // Iceberg metadata is written through S3FileIO only, so any other scheme 
can never work.
+    Preconditions.checkArgument(
+        isS3Location(location),
+        "Iceberg tables need an s3://, s3a:// or s3n:// location; got '%s'. 
Set '%s' on the"
+            + " table, or an S3 '%s' on the catalog or LocationUri on the 
database.",
+        location,
+        GlueConstants.LOCATION,
+        GlueConstants.WAREHOUSE);
 
     String format = properties.get(GlueConstants.FORMAT);
     if (format != null) {
diff --git 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueTableOperations.java
 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueTableOperations.java
index 7b5af82f49..98935ac169 100644
--- 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueTableOperations.java
+++ 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueTableOperations.java
@@ -32,9 +32,9 @@ import org.apache.gravitino.rel.expressions.literals.Literals;
 import org.apache.gravitino.rel.partitions.IdentityPartition;
 import org.apache.gravitino.rel.partitions.Partition;
 import org.apache.gravitino.rel.partitions.Partitions;
-import org.apache.gravitino.utils.ExceptionMessages;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.core.exception.SdkException;
 import software.amazon.awssdk.services.glue.GlueClient;
 import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
 import software.amazon.awssdk.services.glue.model.CreatePartitionRequest;
@@ -43,7 +43,6 @@ import 
software.amazon.awssdk.services.glue.model.EntityNotFoundException;
 import software.amazon.awssdk.services.glue.model.GetPartitionRequest;
 import software.amazon.awssdk.services.glue.model.GetPartitionsRequest;
 import software.amazon.awssdk.services.glue.model.GetPartitionsResponse;
-import software.amazon.awssdk.services.glue.model.GlueException;
 import software.amazon.awssdk.services.glue.model.PartitionInput;
 import software.amazon.awssdk.services.glue.model.StorageDescriptor;
 
@@ -61,6 +60,8 @@ class GlueTableOperations implements TableOperations, 
SupportsPartitions {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(GlueTableOperations.class);
 
+  private static final int MAX_PARTITION_VALUE_LENGTH = 1024;
+
   private final GlueClient glueClient;
   /** Nullable — when null, Glue uses the caller's AWS account ID. */
   private final String catalogId;
@@ -99,8 +100,9 @@ class GlueTableOperations implements TableOperations, 
SupportsPartitions {
         }
         nextToken = resp.nextToken();
       } while (nextToken != null);
-    } catch (GlueException e) {
-      throw ExceptionMessages.wrap("Failed to list partitions for table " + 
tableName, e);
+    } catch (SdkException e) {
+      throw GlueExceptionConverter.toGenericException(
+          e, "listing partitions of table " + tableName, glueClient);
     }
     return names.toArray(new String[0]);
   }
@@ -121,8 +123,9 @@ class GlueTableOperations implements TableOperations, 
SupportsPartitions {
         }
         nextToken = resp.nextToken();
       } while (nextToken != null);
-    } catch (GlueException e) {
-      throw ExceptionMessages.wrap("Failed to list partitions for table " + 
tableName, e);
+    } catch (SdkException e) {
+      throw GlueExceptionConverter.toGenericException(
+          e, "listing partitions of table " + tableName, glueClient);
     }
     return partitions.toArray(new Partition[0]);
   }
@@ -141,8 +144,9 @@ class GlueTableOperations implements TableOperations, 
SupportsPartitions {
     } catch (EntityNotFoundException e) {
       throw new NoSuchPartitionException(
           e, "Partition %s does not exist in table %s", partitionName, 
tableName);
-    } catch (GlueException e) {
-      throw ExceptionMessages.wrap("Failed to get partition " + partitionName, 
e);
+    } catch (SdkException e) {
+      throw GlueExceptionConverter.toGenericException(
+          e, "partition " + partitionName + " of table " + tableName, 
glueClient);
     }
   }
 
@@ -158,8 +162,20 @@ class GlueTableOperations implements TableOperations, 
SupportsPartitions {
         partitionColNames.length);
 
     List<String> values = new ArrayList<>(ip.values().length);
-    for (Literal<?> v : ip.values()) {
-      values.add(v.value() != null ? v.value().toString() : null);
+    for (int i = 0; i < ip.values().length; i++) {
+      Object value = ip.values()[i].value();
+      // Glue limits partition values to 1024 characters. Null and empty 
values are rejected because
+      // a Hive-style partition name cannot represent them.
+      Preconditions.checkArgument(
+          value != null && !value.toString().isEmpty(),
+          "Value of partition column %s must not be null or empty",
+          partitionColNames[i]);
+      Preconditions.checkArgument(
+          value.toString().length() <= MAX_PARTITION_VALUE_LENGTH,
+          "Value of partition column %s exceeds the Glue limit of %s 
characters",
+          partitionColNames[i],
+          MAX_PARTITION_VALUE_LENGTH);
+      values.add(value.toString());
     }
 
     PartitionInput input =
@@ -181,8 +197,9 @@ class GlueTableOperations implements TableOperations, 
SupportsPartitions {
     } catch (AlreadyExistsException e) {
       throw new PartitionAlreadyExistsException(
           e, "Partition %s already exists in table %s", partition.name(), 
tableName);
-    } catch (GlueException e) {
-      throw ExceptionMessages.wrap("Failed to add partition " + 
partition.name(), e);
+    } catch (SdkException e) {
+      throw GlueExceptionConverter.toGenericException(
+          e, "adding partition " + partition.name() + " to table " + 
tableName, glueClient);
     }
 
     LOG.info("Added partition {} to {}.{}", partition.name(), dbName, 
tableName);
@@ -210,8 +227,9 @@ class GlueTableOperations implements TableOperations, 
SupportsPartitions {
       return true;
     } catch (EntityNotFoundException e) {
       return false;
-    } catch (GlueException e) {
-      throw ExceptionMessages.wrap("Failed to drop partition " + 
partitionName, e);
+    } catch (SdkException e) {
+      throw GlueExceptionConverter.toGenericException(
+          e, "dropping partition " + partitionName + " of table " + tableName, 
glueClient);
     }
   }
 
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogCredentials.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogCredentials.java
index 90ee4804c5..7ca612c0b3 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogCredentials.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogCredentials.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.catalog.glue;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.time.Instant;
@@ -149,4 +150,39 @@ public class TestGlueCatalogCredentials {
     String providers = 
withProviders.get(CredentialConstants.CREDENTIAL_PROVIDERS);
     return 
Arrays.stream(providers.split(",")).map(String::trim).collect(Collectors.toList());
   }
+
+  @Test
+  void testValidateOnCreateRunsLocalConfigChecks() {
+    GlueCatalog catalog =
+        new GlueCatalog()
+            .withCatalogConf(
+                Map.of(
+                    GlueConstants.AWS_REGION,
+                    "us-east-1",
+                    GlueConstants.AWS_ACCESS_KEY_ID,
+                    "AKIAEXAMPLE"));
+
+    assertTrue(catalog.shouldValidateOnCreate());
+    IllegalArgumentException partial =
+        assertThrows(IllegalArgumentException.class, 
catalog::validateOnCreate);
+    
assertTrue(partial.getMessage().contains(GlueConstants.AWS_SECRET_ACCESS_KEY));
+
+    GlueCatalog temporaryKey =
+        new GlueCatalog()
+            .withCatalogConf(
+                Map.of(
+                    GlueConstants.AWS_REGION,
+                    "us-east-1",
+                    GlueConstants.AWS_ACCESS_KEY_ID,
+                    "ASIAEXAMPLE",
+                    GlueConstants.AWS_SECRET_ACCESS_KEY,
+                    "secret"));
+    IllegalArgumentException temporary =
+        assertThrows(IllegalArgumentException.class, 
temporaryKey::validateOnCreate);
+    assertTrue(temporary.getMessage().contains("session token"), 
temporary.getMessage());
+
+    new GlueCatalog()
+        .withCatalogConf(Map.of(GlueConstants.AWS_REGION, "us-east-1"))
+        .validateOnCreate();
+  }
 }
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
index 96e950c3f2..a188025933 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
@@ -33,6 +33,8 @@ import static org.mockito.Mockito.when;
 import java.util.Map;
 import java.util.Set;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.TableAlreadyExistsException;
 import org.apache.gravitino.rel.SupportsPartitions;
 import org.apache.gravitino.rel.TableChange;
 import org.apache.gravitino.rel.expressions.distributions.Distributions;
@@ -47,6 +49,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
+import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
 import software.amazon.awssdk.services.glue.GlueClient;
 import software.amazon.awssdk.services.glue.model.Database;
 import software.amazon.awssdk.services.glue.model.GetDatabaseRequest;
@@ -55,6 +58,7 @@ import 
software.amazon.awssdk.services.glue.model.GetPartitionsRequest;
 import software.amazon.awssdk.services.glue.model.GetPartitionsResponse;
 import software.amazon.awssdk.services.glue.model.GetTableRequest;
 import software.amazon.awssdk.services.glue.model.GetTableResponse;
+import software.amazon.awssdk.services.glue.model.GlueException;
 import software.amazon.awssdk.services.glue.model.StorageDescriptor;
 import software.amazon.awssdk.services.glue.model.UpdateTableRequest;
 
@@ -244,6 +248,92 @@ class TestGlueCatalogOperationsForIceberg {
     assertEquals(TABLE, captor.getValue().tableName());
   }
 
+  @Test
+  void testAlterIcebergTableRenameOntoExistingTableIsAlreadyExists() {
+    software.amazon.awssdk.services.glue.model.Table rawTable =
+        software.amazon.awssdk.services.glue.model.Table.builder()
+            .name(TABLE)
+            .parameters(Map.of(TABLE_TYPE_PARAM, ICEBERG_TABLE_TYPE_VALUE))
+            .storageDescriptor(StorageDescriptor.builder().build())
+            .build();
+    when(mockClient.getTable(any(GetTableRequest.class)))
+        .thenReturn(GetTableResponse.builder().table(rawTable).build());
+    Mockito.doThrow(
+            new org.apache.iceberg.exceptions.AlreadyExistsException(
+                "Table already exists: %s.%s.taken",
+                GlueIcebergTableHelper.ICEBERG_CATALOG_NAME, DB))
+        .when(mockIcebergCatalog)
+        .renameTable(any(TableIdentifier.class), any(TableIdentifier.class));
+
+    NameIdentifier ident = NameIdentifier.of("cat", "ns", DB, TABLE);
+    TableAlreadyExistsException e =
+        assertThrows(
+            TableAlreadyExistsException.class,
+            () -> ops.alterTable(ident, TableChange.rename("taken")));
+    assertFalse(
+        e.getMessage().contains(GlueIcebergTableHelper.ICEBERG_CATALOG_NAME), 
e.getMessage());
+  }
+
+  @Test
+  void testCreateIcebergTableWithRejectedCredentialsIsForbidden() {
+    Catalog.TableBuilder mockBuilder = mock(Catalog.TableBuilder.class, 
Mockito.RETURNS_SELF);
+    when(mockIcebergCatalog.buildTable(any(TableIdentifier.class), 
any(Schema.class)))
+        .thenReturn(mockBuilder);
+    when(mockBuilder.create())
+        .thenThrow(
+            new RuntimeException(
+                GlueException.builder()
+                    .message("The security token included in the request is 
invalid")
+                    .awsErrorDetails(
+                        AwsErrorDetails.builder()
+                            .errorCode("UnrecognizedClientException")
+                            .errorMessage("The security token included in the 
request is invalid")
+                            .build())
+                    .build()));
+
+    assertThrows(
+        ForbiddenException.class,
+        () ->
+            ops.createTable(
+                NameIdentifier.of("cat", "ns", DB, TABLE),
+                icebergColumns(),
+                null,
+                Map.of(GlueConstants.TABLE_FORMAT, "iceberg", 
GlueConstants.LOCATION, LOCATION),
+                new Transform[0],
+                Distributions.NONE,
+                null,
+                Indexes.EMPTY_INDEXES));
+  }
+
+  @Test
+  void testCreateIcebergTableRequiresS3Location() {
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                ops.createTable(
+                    NameIdentifier.of("cat", "ns", DB, TABLE),
+                    icebergColumns(),
+                    null,
+                    Map.of(
+                        GlueConstants.TABLE_FORMAT,
+                        "iceberg",
+                        GlueConstants.LOCATION,
+                        "hdfs://nn:8020/warehouse/ice1"),
+                    new Transform[0],
+                    Distributions.NONE,
+                    null,
+                    Indexes.EMPTY_INDEXES));
+    assertTrue(e.getMessage().contains("hdfs://nn:8020/warehouse/ice1"), 
e.getMessage());
+    verify(mockIcebergCatalog, never()).buildTable(any(TableIdentifier.class), 
any(Schema.class));
+  }
+
+  private static GlueColumn[] icebergColumns() {
+    return new GlueColumn[] {
+      
GlueColumn.builder().withName("id").withType(Types.LongType.get()).withNullable(false).build()
+    };
+  }
+
   @Test
   void testAlterIcebergTableRename() {
     String newName = "ice1_renamed";
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogPropertiesMetadata.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogPropertiesMetadata.java
index b9831c9acc..c3fd47d0c2 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogPropertiesMetadata.java
@@ -19,8 +19,11 @@
 package org.apache.gravitino.catalog.glue;
 
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_ACCESS_KEY_ID;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_API_CALL_TIMEOUT_MS;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_CATALOG_ID;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_ENDPOINT;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_MAX_ATTEMPTS;
 import static org.apache.gravitino.catalog.glue.GlueConstants.AWS_REGION;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_SECRET_ACCESS_KEY;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.DEFAULT_TABLE_FORMAT;
@@ -31,6 +34,7 @@ import static 
org.apache.gravitino.catalog.glue.GlueConstants.WAREHOUSE;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import org.apache.gravitino.cloud.storage.AWSPropertiesMetadata;
@@ -107,4 +111,78 @@ class TestGlueCatalogPropertiesMetadata {
         metadata.getDefaultValue(TABLE_FORMAT_FILTER),
         "Default table format filter should be 'all'");
   }
+
+  @Test
+  void testRegionMustBeKnownLowerCaseRegion() {
+    decode(AWS_REGION, "us-east-2");
+    assertInvalid(AWS_REGION, "us-east-9");
+    assertInvalid(AWS_REGION, "US-EAST-2");
+    assertInvalid(AWS_REGION, "us-east-2 ");
+    assertInvalid(AWS_REGION, "");
+  }
+
+  @Test
+  void testCatalogIdMustBeTwelveDigits() {
+    decode(AWS_GLUE_CATALOG_ID, "123456789012");
+    assertInvalid(AWS_GLUE_CATALOG_ID, "12345678901");
+    assertInvalid(AWS_GLUE_CATALOG_ID, "notanumber");
+  }
+
+  @Test
+  void testEndpointMustBeHttpUrl() {
+    decode(AWS_GLUE_ENDPOINT, "http://localhost:4566";);
+    decode(AWS_GLUE_ENDPOINT, "https://glue.us-east-2.amazonaws.com";);
+    decode(AWS_GLUE_ENDPOINT, "http://moto_server:5000";);
+    assertInvalid(AWS_GLUE_ENDPOINT, "http://:4566";);
+    assertInvalid(AWS_GLUE_ENDPOINT, "http://user@";);
+    assertInvalid(AWS_GLUE_ENDPOINT, "not a url");
+    assertInvalid(AWS_GLUE_ENDPOINT, "ht!tp://bad");
+    assertInvalid(AWS_GLUE_ENDPOINT, "glue.us-east-2.amazonaws.com");
+  }
+
+  @Test
+  void testWarehouseMustBeUriWithScheme() {
+    decode(WAREHOUSE, "s3://bucket/warehouse");
+    decode(WAREHOUSE, "s3a://bucket");
+    // Hive-only catalogs may keep their tables on other file systems.
+    decode(WAREHOUSE, "hdfs://nn:8020/user/hive/warehouse");
+    decode(WAREHOUSE, "file:///tmp/warehouse");
+    assertInvalid(WAREHOUSE, "/tmp/warehouse");
+    assertInvalid(WAREHOUSE, "s3://");
+    assertInvalid(WAREHOUSE, "");
+  }
+
+  @Test
+  void testTableFormatValuesMustBeDocumented() {
+    decode(DEFAULT_TABLE_FORMAT, "ICEBERG");
+    decode(DEFAULT_TABLE_FORMAT, "hive");
+    assertInvalid(DEFAULT_TABLE_FORMAT, "paimon");
+    assertInvalid(DEFAULT_TABLE_FORMAT, "");
+
+    decode(TABLE_FORMAT_FILTER, "iceberg, hive");
+    decode(TABLE_FORMAT_FILTER, "all");
+    assertInvalid(TABLE_FORMAT_FILTER, "icebrg");
+    assertInvalid(TABLE_FORMAT_FILTER, "iceberg,");
+    assertInvalid(TABLE_FORMAT_FILTER, "");
+  }
+
+  @Test
+  void testTimeoutAndRetryValuesMustBePositive() {
+    assertEquals(5000L, decode(AWS_GLUE_API_CALL_TIMEOUT_MS, "5000"));
+    assertEquals(3, decode(AWS_GLUE_MAX_ATTEMPTS, "3"));
+    assertInvalid(AWS_GLUE_API_CALL_TIMEOUT_MS, "0");
+    assertInvalid(AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS, "-1");
+    assertInvalid(AWS_GLUE_MAX_ATTEMPTS, "zero");
+  }
+
+  private Object decode(String key, String value) {
+    return metadata.getPropertyEntry(key).decode(value);
+  }
+
+  private void assertInvalid(String key, String value) {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> decode(key, value),
+        () -> key + " should reject '" + value + "'");
+  }
 }
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
index 6bebc1baac..d1eb719ee6 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
@@ -32,17 +32,21 @@ import java.time.Instant;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.SchemaChange;
 import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NonEmptySchemaException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
+import org.apache.gravitino.exceptions.ServiceUnavailableException;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
+import software.amazon.awssdk.core.exception.ApiCallTimeoutException;
 import software.amazon.awssdk.core.exception.SdkClientException;
 import software.amazon.awssdk.services.glue.GlueClient;
 import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
@@ -58,8 +62,10 @@ import 
software.amazon.awssdk.services.glue.model.GetTablesRequest;
 import software.amazon.awssdk.services.glue.model.GetTablesResponse;
 import software.amazon.awssdk.services.glue.model.GlueException;
 import software.amazon.awssdk.services.glue.model.Table;
+import software.amazon.awssdk.services.glue.model.ThrottlingException;
 import software.amazon.awssdk.services.glue.model.UpdateDatabaseRequest;
 import software.amazon.awssdk.services.glue.model.UpdateDatabaseResponse;
+import software.amazon.awssdk.services.glue.model.ValidationException;
 
 class TestGlueCatalogSchemaOperations {
 
@@ -179,7 +185,7 @@ class TestGlueCatalogSchemaOperations {
     GlueException cause = invalidCredentialsException();
     
when(mockClient.getDatabases(any(GetDatabasesRequest.class))).thenThrow(cause);
 
-    RuntimeException ex = assertThrows(RuntimeException.class, () -> 
ops.listSchemas(ns));
+    ForbiddenException ex = assertThrows(ForbiddenException.class, () -> 
ops.listSchemas(ns));
 
     assertEquals(cause, ex.getCause());
     assertTrue(ex.getMessage().contains(GlueConstants.AWS_ACCESS_KEY_ID));
@@ -188,14 +194,94 @@ class TestGlueCatalogSchemaOperations {
   }
 
   @Test
-  void testListSchemasRethrowsNonCredentialSdkClientException() {
+  void testListSchemasMapsUnreachableEndpointToConnectionFailure() {
     Namespace ns = Namespace.of("metalake", "catalog");
     SdkClientException cause = SdkClientException.create("connection refused");
     
when(mockClient.getDatabases(any(GetDatabasesRequest.class))).thenThrow(cause);
 
-    SdkClientException ex = assertThrows(SdkClientException.class, () -> 
ops.listSchemas(ns));
+    ConnectionFailedException ex =
+        assertThrows(ConnectionFailedException.class, () -> 
ops.listSchemas(ns));
+
+    assertEquals(cause, ex.getCause());
+    assertTrue(ex.getMessage().contains("connection refused"), 
ex.getMessage());
+    assertTrue(ex.getMessage().contains("Failed to reach AWS Glue"), 
ex.getMessage());
+  }
+
+  @Test
+  void testListSchemasMapsTimeoutToConnectionFailure() {
+    Namespace ns = Namespace.of("metalake", "catalog");
+    when(mockClient.getDatabases(any(GetDatabasesRequest.class)))
+        .thenThrow(ApiCallTimeoutException.create(30_000L));
+
+    ConnectionFailedException ex =
+        assertThrows(ConnectionFailedException.class, () -> 
ops.listSchemas(ns));
+
+    assertTrue(ex.getMessage().contains("timed out"), ex.getMessage());
+  }
+
+  @Test
+  void testListSchemasMapsRegionMismatchToIllegalArgument() {
+    Namespace ns = Namespace.of("metalake", "catalog");
+    GlueException cause =
+        glueException(
+            "InvalidSignatureException", "Credential should be scoped to a 
valid region.");
+    
when(mockClient.getDatabases(any(GetDatabasesRequest.class))).thenThrow(cause);
+
+    IllegalArgumentException ex =
+        assertThrows(IllegalArgumentException.class, () -> 
ops.listSchemas(ns));
 
-    assertEquals(cause, ex);
+    assertTrue(ex.getMessage().contains(GlueConstants.AWS_REGION), 
ex.getMessage());
+    assertTrue(ex.getMessage().contains(GlueConstants.AWS_GLUE_ENDPOINT), 
ex.getMessage());
+    assertFalse(ex.getMessage().contains(GlueConstants.AWS_SECRET_ACCESS_KEY), 
ex.getMessage());
+  }
+
+  @Test
+  void testLoadSchemaMapsThrottlingToServiceUnavailable() {
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "db");
+    when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+        .thenThrow(
+            ThrottlingException.builder()
+                .message("Rate exceeded")
+                .awsErrorDetails(
+                    AwsErrorDetails.builder()
+                        .errorCode("ThrottlingException")
+                        .errorMessage("Rate exceeded")
+                        .build())
+                .build());
+
+    ServiceUnavailableException ex =
+        assertThrows(ServiceUnavailableException.class, () -> 
ops.loadSchema(ident));
+
+    assertTrue(ex.getMessage().contains("can be retried"), ex.getMessage());
+  }
+
+  @Test
+  void 
testCreateSchemaMapsValidationExceptionToIllegalArgumentWithShortMessage() {
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "db");
+    String longValue = StringUtils.repeat('v', 600_000);
+    String awsMessage =
+        "1 validation error detected: Value '{k="
+            + longValue
+            + "}' at 'database.parameters' failed to satisfy constraint: Map 
value must satisfy"
+            + " constraint";
+    when(mockClient.createDatabase(any(CreateDatabaseRequest.class)))
+        .thenThrow(
+            ValidationException.builder()
+                .message(awsMessage)
+                .awsErrorDetails(
+                    AwsErrorDetails.builder()
+                        .errorCode("ValidationException")
+                        .errorMessage(awsMessage)
+                        .build())
+                .build());
+
+    IllegalArgumentException ex =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> ops.createSchema(ident, null, Collections.emptyMap()));
+
+    assertTrue(ex.getMessage().length() < 2_000, "message length " + 
ex.getMessage().length());
+    assertTrue(ex.getMessage().contains("failed to satisfy constraint"), 
ex.getMessage());
   }
 
   // -------------------------------------------------------------------------
@@ -385,6 +471,16 @@ class TestGlueCatalogSchemaOperations {
     assertFalse(dropped);
   }
 
+  @Test
+  void testDropSchemaNotFoundWithoutCascadeReturnsFalse() {
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", 
"missing_db");
+    when(mockClient.getTables(any(GetTablesRequest.class)))
+        .thenThrow(EntityNotFoundException.builder().message("Database not 
found").build());
+
+    assertFalse(ops.dropSchema(ident, false));
+    verify(mockClient, 
never()).deleteDatabase(any(DeleteDatabaseRequest.class));
+  }
+
   @Test
   void testDropSchemaWithCatalogId() {
     ops.catalogId = "123456789012";
@@ -401,6 +497,15 @@ class TestGlueCatalogSchemaOperations {
     assertEquals("123456789012", captor.getValue().catalogId());
   }
 
+  private static GlueException glueException(String code, String message) {
+    return (GlueException)
+        GlueException.builder()
+            .message(message)
+            .awsErrorDetails(
+                
AwsErrorDetails.builder().errorCode(code).errorMessage(message).build())
+            .build();
+  }
+
   private static GlueException invalidCredentialsException() {
     return (GlueException)
         GlueException.builder()
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
index ffc9babbb7..9c9a7a1b25 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
@@ -141,6 +141,114 @@ class TestGlueCatalogTableOperations {
     assertEquals("ice_tbl", result[0].name());
   }
 
+  @Test
+  void testFormatFilterHidesTableFromLoadAlterAndDrop() {
+    ops.tableFormatFilter = Set.of("iceberg");
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"hive_tbl");
+    Table hiveTable =
+        Table.builder()
+            .name("hive_tbl")
+            .parameters(Collections.emptyMap())
+            .storageDescriptor(StorageDescriptor.builder().build())
+            .build();
+    when(mockClient.getTable(any(GetTableRequest.class)))
+        .thenReturn(GetTableResponse.builder().table(hiveTable).build());
+
+    assertThrows(NoSuchTableException.class, () -> ops.loadTable(ident));
+    assertThrows(
+        NoSuchTableException.class, () -> ops.alterTable(ident, 
TableChange.setProperty("k", "v")));
+    assertFalse(ops.dropTable(ident));
+    verify(mockClient, never()).updateTable(any(UpdateTableRequest.class));
+    verify(mockClient, never()).deleteTable(any(DeleteTableRequest.class));
+  }
+
+  @Test
+  void testFormatFilterRefusesCreateOfHiddenFormat() {
+    ops.tableFormatFilter = Set.of("iceberg");
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"hive_tbl");
+    Column[] columns = {
+      
GlueColumn.builder().withName("id").withType(Types.LongType.get()).withNullable(true).build()
+    };
+
+    IllegalArgumentException ex =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                ops.createTable(
+                    ident,
+                    columns,
+                    null,
+                    Collections.emptyMap(),
+                    Transforms.EMPTY_TRANSFORM,
+                    Distributions.NONE,
+                    SortOrders.NONE,
+                    Indexes.EMPTY_INDEXES));
+    assertTrue(ex.getMessage().contains(GlueConstants.TABLE_FORMAT_FILTER), 
ex.getMessage());
+    verify(mockClient, never()).createTable(any(CreateTableRequest.class));
+  }
+
+  @Test
+  void testFormatFilterKeepsVisibleFormatUsable() {
+    ops.tableFormatFilter = Set.of("iceberg");
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"ice_tbl");
+    Table icebergTable =
+        Table.builder()
+            .name("ice_tbl")
+            .parameters(Map.of(GlueConstants.TABLE_FORMAT, "ICEBERG"))
+            .storageDescriptor(StorageDescriptor.builder().build())
+            .build();
+    when(mockClient.getTable(any(GetTableRequest.class)))
+        .thenReturn(GetTableResponse.builder().table(icebergTable).build());
+
+    assertEquals("ice_tbl", ops.loadTable(ident).name());
+  }
+
+  @Test
+  void testFormatFilterCreateUsesDefaultTableFormat() {
+    ops.tableFormatFilter = Set.of("hive");
+    ops.defaultTableFormat = "iceberg";
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"hive_tbl");
+    Column[] columns = {
+      
GlueColumn.builder().withName("id").withType(Types.LongType.get()).withNullable(true).build()
+    };
+
+    // Without table-format the default (iceberg) applies, which the filter 
hides.
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ops.createTable(
+                ident,
+                columns,
+                null,
+                Collections.emptyMap(),
+                Transforms.EMPTY_TRANSFORM,
+                Distributions.NONE,
+                SortOrders.NONE,
+                Indexes.EMPTY_INDEXES));
+
+    ops.createTable(
+        ident,
+        columns,
+        null,
+        Map.of(GlueConstants.TABLE_FORMAT, "hive", GlueConstants.LOCATION, 
"s3://bucket/path"),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        SortOrders.NONE,
+        Indexes.EMPTY_INDEXES);
+    verify(mockClient).createTable(any(CreateTableRequest.class));
+  }
+
+  @Test
+  void testFormatFilterAllExposesEveryFormat() {
+    ops.tableFormatFilter = Set.of("all", "iceberg");
+    Namespace ns = Namespace.of("metalake", "catalog", "mydb");
+    Table hiveTable = 
Table.builder().name("hive_tbl").parameters(Collections.emptyMap()).build();
+    when(mockClient.getTables(any(GetTablesRequest.class)))
+        
.thenReturn(GetTablesResponse.builder().tableList(hiveTable).nextToken(null).build());
+
+    assertEquals(1, ops.listTables(ns).length);
+  }
+
   // -------------------------------------------------------------------------
   // loadTable
   // -------------------------------------------------------------------------
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueClientProvider.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueClientProvider.java
index 665e76d72b..3cc207052b 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueClientProvider.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueClientProvider.java
@@ -19,7 +19,10 @@
 package org.apache.gravitino.catalog.glue;
 
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_ACCESS_KEY_ID;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_API_CALL_TIMEOUT_MS;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_ENDPOINT;
+import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_GLUE_MAX_ATTEMPTS;
 import static org.apache.gravitino.catalog.glue.GlueConstants.AWS_REGION;
 import static 
org.apache.gravitino.catalog.glue.GlueConstants.AWS_SECRET_ACCESS_KEY;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -30,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 
+import java.time.Duration;
 import java.util.HashMap;
 import java.util.Map;
 import org.apache.gravitino.exceptions.ConnectionFailedException;
@@ -37,6 +41,7 @@ import org.junit.jupiter.api.Test;
 import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
 import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
 import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
 import software.amazon.awssdk.core.exception.SdkClientException;
 import software.amazon.awssdk.services.glue.GlueClient;
 
@@ -164,4 +169,81 @@ class TestGlueClientProvider {
 
     assertThrows(IllegalArgumentException.class, () -> 
GlueClientProvider.buildClient(config));
   }
+
+  @Test
+  void testBuildClientAppliesTimeoutsAndRetries() {
+    Map<String, String> config = new HashMap<>();
+    config.put(AWS_REGION, "us-east-1");
+    config.put(AWS_ACCESS_KEY_ID, "test-access-key");
+    config.put(AWS_SECRET_ACCESS_KEY, "test-secret-key");
+    config.put(AWS_GLUE_API_CALL_TIMEOUT_MS, "5000");
+    config.put(AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS, "2000");
+    config.put(AWS_GLUE_MAX_ATTEMPTS, "2");
+
+    try (GlueClient client = GlueClientProvider.buildClient(config)) {
+      ClientOverrideConfiguration override =
+          client.serviceClientConfiguration().overrideConfiguration();
+      assertEquals(Duration.ofMillis(5000), 
override.apiCallTimeout().orElseThrow());
+      assertEquals(Duration.ofMillis(2000), 
override.apiCallAttemptTimeout().orElseThrow());
+      assertEquals(2, override.retryStrategy().orElseThrow().maxAttempts());
+    }
+  }
+
+  @Test
+  void testBuildClientDefaultTimeouts() {
+    Map<String, String> config = new HashMap<>();
+    config.put(AWS_REGION, "us-east-1");
+    config.put(AWS_ACCESS_KEY_ID, "test-access-key");
+    config.put(AWS_SECRET_ACCESS_KEY, "test-secret-key");
+
+    try (GlueClient client = GlueClientProvider.buildClient(config)) {
+      ClientOverrideConfiguration override =
+          client.serviceClientConfiguration().overrideConfiguration();
+      assertEquals(
+          
Duration.ofMillis(GlueConstants.DEFAULT_AWS_GLUE_API_CALL_TIMEOUT_MS),
+          override.apiCallTimeout().orElseThrow());
+      assertEquals(
+          GlueConstants.DEFAULT_AWS_GLUE_MAX_ATTEMPTS,
+          override.retryStrategy().orElseThrow().maxAttempts());
+    }
+  }
+
+  @Test
+  void testValidateConfigRejectsTemporaryAccessKey() {
+    Map<String, String> config = new HashMap<>();
+    config.put(AWS_REGION, "us-east-1");
+    config.put(AWS_ACCESS_KEY_ID, "ASIAEXAMPLEKEY");
+    config.put(AWS_SECRET_ACCESS_KEY, "secret");
+
+    IllegalArgumentException ex =
+        assertThrows(
+            IllegalArgumentException.class, () -> 
GlueClientProvider.validateConfig(config));
+    assertTrue(ex.getMessage().contains("session token"), ex.getMessage());
+    assertTrue(ex.getMessage().contains("default AWS credential chain"), 
ex.getMessage());
+    assertFalse(ex.getMessage().contains("ASIAEXAMPLEKEY"), ex.getMessage());
+  }
+
+  @Test
+  void testValidateConfigRejectsPartialKeyPairAndBadEndpoint() {
+    Map<String, String> partial = new HashMap<>();
+    partial.put(AWS_REGION, "us-east-1");
+    partial.put(AWS_ACCESS_KEY_ID, "AKIAEXAMPLE");
+    partial.put(AWS_SECRET_ACCESS_KEY, "   ");
+    assertThrows(IllegalArgumentException.class, () -> 
GlueClientProvider.validateConfig(partial));
+
+    Map<String, String> badEndpoint = new HashMap<>();
+    badEndpoint.put(AWS_REGION, "us-east-1");
+    badEndpoint.put(AWS_GLUE_ENDPOINT, "not a url");
+    IllegalArgumentException ex =
+        assertThrows(
+            IllegalArgumentException.class, () -> 
GlueClientProvider.validateConfig(badEndpoint));
+    assertTrue(ex.getMessage().contains(AWS_GLUE_ENDPOINT), ex.getMessage());
+  }
+
+  @Test
+  void testValidateConfigAcceptsDefaultCredentialChain() {
+    Map<String, String> config = new HashMap<>();
+    config.put(AWS_REGION, "us-east-1");
+    GlueClientProvider.validateConfig(config);
+  }
 }
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueExceptionConverter.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueExceptionConverter.java
index bb6ff91378..dd47890432 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueExceptionConverter.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueExceptionConverter.java
@@ -24,19 +24,26 @@ import static 
org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import org.apache.gravitino.exceptions.ConnectionFailedException;
 import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
+import org.apache.gravitino.exceptions.ServiceUnavailableException;
 import org.apache.gravitino.exceptions.TableAlreadyExistsException;
 import org.junit.jupiter.api.Test;
 import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
+import software.amazon.awssdk.awscore.exception.AwsServiceException;
 import software.amazon.awssdk.core.exception.SdkClientException;
 import software.amazon.awssdk.services.glue.model.AccessDeniedException;
 import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
+import 
software.amazon.awssdk.services.glue.model.ConcurrentModificationException;
 import software.amazon.awssdk.services.glue.model.EntityNotFoundException;
 import software.amazon.awssdk.services.glue.model.GlueException;
 import software.amazon.awssdk.services.glue.model.InvalidInputException;
+import software.amazon.awssdk.services.glue.model.ThrottlingException;
 
 /** Tests for {@link GlueExceptionConverter}. */
 public class TestGlueExceptionConverter {
@@ -107,6 +114,9 @@ public class TestGlueExceptionConverter {
 
     RuntimeException converted = 
GlueExceptionConverter.toSchemaException(cause, "listing schemas");
 
+    assertInstanceOf(ForbiddenException.class, converted);
+    assertInstanceOf(
+        ForbiddenException.class, 
GlueExceptionConverter.toTableException(cause, "table t"));
     assertSame(cause, converted.getCause());
     
assertTrue(converted.getMessage().contains(GlueConstants.AWS_ACCESS_KEY_ID));
     
assertTrue(converted.getMessage().contains(GlueConstants.AWS_SECRET_ACCESS_KEY));
@@ -259,4 +269,237 @@ public class TestGlueExceptionConverter {
                     .build())
             .build();
   }
+
+  @Test
+  public void testIcebergForbiddenWithNonGlueCauseStaysForbidden() {
+    AwsServiceException s3Denial =
+        AwsServiceException.builder()
+            .message("Access Denied")
+            .statusCode(403)
+            .awsErrorDetails(
+                AwsErrorDetails.builder()
+                    .serviceName("S3")
+                    .errorCode("AccessDenied")
+                    .errorMessage("Access Denied")
+                    .build())
+            .build();
+    RuntimeException raw =
+        new org.apache.iceberg.exceptions.ForbiddenException(
+            s3Denial, "Cannot commit db.t because S3 denied access");
+
+    RuntimeException converted = 
GlueExceptionConverter.toIcebergException(raw, "table t");
+
+    assertInstanceOf(ForbiddenException.class, converted);
+    assertTrue(converted.getMessage().contains("Access Denied"), 
converted.getMessage());
+  }
+
+  @Test
+  public void testIcebergRawGlueNotFoundIsNotFound() {
+    RuntimeException raw =
+        new RuntimeException(
+            EntityNotFoundException.builder().message("Database db not 
found").build());
+
+    assertInstanceOf(
+        NotFoundException.class, 
GlueExceptionConverter.toIcebergException(raw, "table t"));
+  }
+
+  @Test
+  public void testIcebergRenameOntoExistingTableIsAlreadyExists() {
+    RuntimeException raw =
+        new RuntimeException(
+            AlreadyExistsException.builder()
+                .message("Table already exists.")
+                .awsErrorDetails(
+                    AwsErrorDetails.builder()
+                        .errorCode("AlreadyExistsException")
+                        .errorMessage("Table already exists.")
+                        .build())
+                .build());
+
+    assertInstanceOf(
+        TableAlreadyExistsException.class,
+        GlueExceptionConverter.toIcebergException(raw, "altering table t1"));
+  }
+
+  @Test
+  public void testIcebergMissingNamespaceIsNoSuchSchema() {
+    RuntimeException converted =
+        GlueExceptionConverter.toIcebergException(
+            new org.apache.iceberg.exceptions.NoSuchNamespaceException(
+                "Cannot rename db.t to nodb.t because namespace nodb does not 
exist"),
+            "altering table t");
+
+    assertInstanceOf(NoSuchSchemaException.class, converted);
+  }
+
+  @Test
+  public void testIcebergForbiddenKeepsAwsDenialWithoutCatalogName() {
+    AccessDeniedException denial =
+        AccessDeniedException.builder()
+            .message(IAM_MESSAGE)
+            .awsErrorDetails(
+                AwsErrorDetails.builder()
+                    .errorCode("AccessDeniedException")
+                    .errorMessage(IAM_MESSAGE)
+                    .build())
+            .build();
+    RuntimeException raw =
+        new org.apache.iceberg.exceptions.ForbiddenException(
+            denial,
+            "Cannot commit %s.db.t because Glue cannot access the requested 
resources",
+            GlueIcebergTableHelper.ICEBERG_CATALOG_NAME);
+
+    RuntimeException converted = 
GlueExceptionConverter.toIcebergException(raw, "table t");
+
+    assertInstanceOf(ForbiddenException.class, converted);
+    assertTrue(converted.getMessage().contains("glue:CreateDatabase"), 
converted.getMessage());
+    assertFalse(
+        
converted.getMessage().contains(GlueIcebergTableHelper.ICEBERG_CATALOG_NAME),
+        converted.getMessage());
+  }
+
+  @Test
+  public void testIcebergValidationUnderCommitFailureIsIllegalArgument() {
+    RuntimeException raw =
+        new org.apache.iceberg.exceptions.CommitStateUnknownException(
+            new org.apache.iceberg.exceptions.ValidationException(
+                "Invalid S3 URI, cannot determine scheme: 
/tmp/wh/db/t/metadata/0.json"));
+
+    RuntimeException converted = 
GlueExceptionConverter.toIcebergException(raw, "table t");
+
+    assertInstanceOf(IllegalArgumentException.class, converted);
+    assertTrue(converted.getMessage().contains("/tmp/wh/db/t"), 
converted.getMessage());
+  }
+
+  @Test
+  public void testIcebergCommitStateUnknownIsNotConnectionFailure() {
+    RuntimeException raw =
+        new org.apache.iceberg.exceptions.CommitStateUnknownException(
+            SdkClientException.create("Read timed out"));
+
+    RuntimeException converted = 
GlueExceptionConverter.toIcebergException(raw, "table t");
+
+    assertFalse(converted instanceof ConnectionFailedException, 
converted.toString());
+    assertTrue(converted.getMessage().contains("commit state is unknown"), 
converted.getMessage());
+    assertTrue(converted.getMessage().contains("Read timed out"), 
converted.getMessage());
+  }
+
+  @Test
+  public void testIcebergConcurrentCommitIsConflict() {
+    RuntimeException raw =
+        new org.apache.iceberg.exceptions.CommitFailedException(
+            ConcurrentModificationException.builder().message("concurrent 
update").build(),
+            "Cannot commit db.t because Glue detected concurrent update");
+
+    assertInstanceOf(
+        OptimisticLockException.class, 
GlueExceptionConverter.toIcebergException(raw, "table t"));
+  }
+
+  @Test
+  public void testIcebergNotFoundIsNotFound() {
+    RuntimeException raw =
+        new org.apache.iceberg.exceptions.NotFoundException(
+            EntityNotFoundException.builder().message("Database db not 
found").build(),
+            "Cannot commit db.t because Glue cannot find the requested 
entity");
+
+    assertInstanceOf(
+        NotFoundException.class, 
GlueExceptionConverter.toIcebergException(raw, "table t"));
+  }
+
+  @Test
+  public void testIcebergAlreadyExistsIsTableAlreadyExists() {
+    RuntimeException converted =
+        GlueExceptionConverter.toIcebergException(
+            new org.apache.iceberg.exceptions.AlreadyExistsException(
+                "Table already exists: %s.db.t", 
GlueIcebergTableHelper.ICEBERG_CATALOG_NAME),
+            "table t");
+
+    assertInstanceOf(TableAlreadyExistsException.class, converted);
+    assertFalse(
+        
converted.getMessage().contains(GlueIcebergTableHelper.ICEBERG_CATALOG_NAME),
+        converted.getMessage());
+  }
+
+  @Test
+  public void testIcebergThrottlingCauseIsServiceUnavailable() {
+    RuntimeException raw =
+        new RuntimeException(ThrottlingException.builder().message("Rate 
exceeded").build());
+
+    assertInstanceOf(
+        ServiceUnavailableException.class,
+        GlueExceptionConverter.toIcebergException(raw, "table t"));
+  }
+
+  @Test
+  public void testIcebergUnknownFailureFallsBackWithoutNullMessage() {
+    RuntimeException converted =
+        GlueExceptionConverter.toIcebergException(new IllegalStateException(), 
"table t");
+
+    assertEquals(RuntimeException.class, converted.getClass());
+    assertTrue(converted.getMessage().contains("IllegalStateException"), 
converted.getMessage());
+    assertFalse(converted.getMessage().contains("null"), 
converted.getMessage());
+  }
+
+  @Test
+  public void testUnmodeledThrottlingStatusIsServiceUnavailable() {
+    GlueException tooMany =
+        (GlueException)
+            GlueException.builder()
+                .message("Too many requests")
+                .statusCode(429)
+                .awsErrorDetails(
+                    AwsErrorDetails.builder()
+                        .errorCode("TooManyRequestsException")
+                        .errorMessage("Too many requests")
+                        .build())
+                .build();
+
+    assertInstanceOf(
+        ServiceUnavailableException.class,
+        GlueExceptionConverter.toClientException(tooMany, "table t", null));
+  }
+
+  @Test
+  public void testIcebergGravitinoExceptionsPassThrough() {
+    NoSuchTableException original = new NoSuchTableException("gone");
+    assertSame(original, GlueExceptionConverter.toIcebergException(original, 
"table t"));
+    UnsupportedOperationException unsupported =
+        new UnsupportedOperationException("Transform is not supported: 
bucket");
+    assertSame(unsupported, 
GlueExceptionConverter.toIcebergException(unsupported, "table t"));
+  }
+
+  @Test
+  public void testServiceScopeErrorIsNotRegionMismatch() {
+    GlueException serviceError =
+        (GlueException)
+            GlueException.builder()
+                .message("Credential should be scoped to correct service: 
'glue'.")
+                .awsErrorDetails(
+                    AwsErrorDetails.builder()
+                        .errorCode("InvalidSignatureException")
+                        .errorMessage("Credential should be scoped to correct 
service: 'glue'.")
+                        .build())
+                .build();
+
+    assertFalse(GlueExceptionConverter.isRegionScopeFailure(serviceError));
+  }
+
+  @Test
+  public void testRegionScopeErrorIsNotReportedAsRejectedCredentials() {
+    GlueException regionError =
+        (GlueException)
+            GlueException.builder()
+                .message("Credential should be scoped to a valid region.")
+                .awsErrorDetails(
+                    AwsErrorDetails.builder()
+                        .errorCode("InvalidSignatureException")
+                        .errorMessage("Credential should be scoped to a valid 
region.")
+                        .build())
+                .build();
+
+    assertFalse(GlueExceptionConverter.isAuthenticationFailure(regionError));
+    String message = 
GlueExceptionConverter.toConnectionException(regionError).getMessage();
+    assertTrue(message.contains(GlueConstants.AWS_REGION), message);
+    assertFalse(message.contains("rejected the configured credentials"), 
message);
+  }
 }
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueIcebergTableHelper.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueIcebergTableHelper.java
index 5e1bce6029..3daef01cce 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueIcebergTableHelper.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueIcebergTableHelper.java
@@ -336,4 +336,18 @@ class TestGlueIcebergTableHelper {
                 () -> 
GlueIcebergTableHelper.buildIcebergCatalogProperties(config))
             .getMessage());
   }
+
+  @Test
+  void testBuildIcebergCatalogPropertiesAppliesHttpTimeouts() {
+    Map<String, String> config = new HashMap<>();
+    config.put(GlueConstants.AWS_REGION, "us-east-1");
+    config.put(GlueConstants.WAREHOUSE, "s3://test-bucket/warehouse");
+    config.put(GlueConstants.AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS, "2500");
+
+    Map<String, String> icebergProps = 
GlueIcebergTableHelper.buildIcebergCatalogProperties(config);
+
+    assertEquals("apache", icebergProps.get("http-client.type"));
+    assertEquals("2500", 
icebergProps.get("http-client.apache.connection-timeout-ms"));
+    assertEquals("2500", 
icebergProps.get("http-client.apache.socket-timeout-ms"));
+  }
 }
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGluePartitionOperations.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGluePartitionOperations.java
index 0099b39512..3d6eca2169 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGluePartitionOperations.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGluePartitionOperations.java
@@ -24,10 +24,13 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NoSuchPartitionException;
 import org.apache.gravitino.exceptions.PartitionAlreadyExistsException;
 import org.apache.gravitino.rel.expressions.literals.Literals;
@@ -38,6 +41,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import software.amazon.awssdk.services.glue.GlueClient;
+import software.amazon.awssdk.services.glue.model.AccessDeniedException;
 import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
 import software.amazon.awssdk.services.glue.model.CreatePartitionRequest;
 import software.amazon.awssdk.services.glue.model.DeletePartitionRequest;
@@ -46,6 +50,7 @@ import 
software.amazon.awssdk.services.glue.model.GetPartitionRequest;
 import software.amazon.awssdk.services.glue.model.GetPartitionResponse;
 import software.amazon.awssdk.services.glue.model.GetPartitionsRequest;
 import software.amazon.awssdk.services.glue.model.GetPartitionsResponse;
+import software.amazon.awssdk.services.glue.model.InvalidInputException;
 
 class TestGluePartitionOperations {
 
@@ -188,6 +193,84 @@ class TestGluePartitionOperations {
     assertThrows(PartitionAlreadyExistsException.class, () -> 
ops.addPartition(partition));
   }
 
+  @Test
+  void testAddPartitionRejectsNullOrEmptyValue() {
+    for (org.apache.gravitino.rel.expressions.literals.Literal<?> bad :
+        new org.apache.gravitino.rel.expressions.literals.Literal<?>[] {
+          Literals.NULL, Literals.stringLiteral("")
+        }) {
+      IdentityPartition partition =
+          Partitions.identity(
+              "p",
+              new String[][] {{"dt"}, {"region"}},
+              new org.apache.gravitino.rel.expressions.literals.Literal[] {
+                Literals.stringLiteral("2024-01-01"), bad
+              },
+              java.util.Collections.emptyMap());
+
+      IllegalArgumentException ex =
+          assertThrows(IllegalArgumentException.class, () -> 
ops.addPartition(partition));
+      assertTrue(ex.getMessage().contains("region"), ex.getMessage());
+    }
+    verify(mockClient, 
never()).createPartition(any(CreatePartitionRequest.class));
+  }
+
+  @Test
+  void testAddPartitionValueLengthLimit() {
+    IdentityPartition atLimit = partitionWithRegion(StringUtils.repeat('a', 
1024));
+    ops.addPartition(atLimit);
+    verify(mockClient).createPartition(any(CreatePartitionRequest.class));
+
+    IdentityPartition overLimit = partitionWithRegion(StringUtils.repeat('a', 
1025));
+    IllegalArgumentException ex =
+        assertThrows(IllegalArgumentException.class, () -> 
ops.addPartition(overLimit));
+    assertTrue(ex.getMessage().contains("region"), ex.getMessage());
+    verify(mockClient).createPartition(any(CreatePartitionRequest.class));
+  }
+
+  private static IdentityPartition partitionWithRegion(String region) {
+    return Partitions.identity(
+        "p",
+        new String[][] {{"dt"}, {"region"}},
+        new org.apache.gravitino.rel.expressions.literals.Literal[] {
+          Literals.stringLiteral("2024-01-01"), Literals.stringLiteral(region)
+        },
+        java.util.Collections.emptyMap());
+  }
+
+  @Test
+  void testAddPartitionMapsInvalidInputToIllegalArgument() {
+    IdentityPartition partition =
+        Partitions.identity(
+            "dt=2024-01-01/region=us",
+            new String[][] {{"dt"}, {"region"}},
+            new org.apache.gravitino.rel.expressions.literals.Literal[] {
+              Literals.stringLiteral("2024-01-01"), 
Literals.stringLiteral("us")
+            },
+            java.util.Collections.emptyMap());
+    when(mockClient.createPartition(any(CreatePartitionRequest.class)))
+        .thenThrow(
+            InvalidInputException.builder()
+                .message("Cannot create partitions in table without partition 
keys")
+                .build());
+
+    IllegalArgumentException ex =
+        assertThrows(IllegalArgumentException.class, () -> 
ops.addPartition(partition));
+    assertTrue(ex.getMessage().contains("without partition keys"), 
ex.getMessage());
+  }
+
+  @Test
+  void testListPartitionsMapsAccessDeniedToForbidden() {
+    when(mockClient.getPartitions(any(GetPartitionsRequest.class)))
+        .thenThrow(
+            AccessDeniedException.builder()
+                .message("not authorized to perform: glue:GetPartitions")
+                .build());
+
+    ForbiddenException ex = assertThrows(ForbiddenException.class, () -> 
ops.listPartitions());
+    assertTrue(ex.getMessage().contains("glue:GetPartitions"), 
ex.getMessage());
+  }
+
   @Test
   void testAddPartitionRejectsNonIdentity() {
     Partition nonIdentity = mock(Partition.class);
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AbstractGlueCatalogIT.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AbstractGlueCatalogIT.java
index 55e0164b57..29f5e6bdb7 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AbstractGlueCatalogIT.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/AbstractGlueCatalogIT.java
@@ -289,6 +289,52 @@ abstract class AbstractGlueCatalogIT {
     currentSchema = null;
   }
 
+  @Test
+  void testDropMissingSchemaWithoutCascadeReturnsFalse() {
+    assertFalse(ops.dropSchema(schemaIdent("glue_it_missing_" + 
System.nanoTime()), false));
+  }
+
+  @Test
+  void testFormatFilterHidesExcludedTables() throws Exception {
+    String schema = newSchema();
+    ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+    createHiveTable(schema, "hive_tbl");
+
+    Map<String, String> filteredConfig = new HashMap<>(catalogConfig());
+    filteredConfig.put(GlueConstants.TABLE_FORMAT_FILTER, "iceberg");
+    GlueCatalogOperations filteredOps = new GlueCatalogOperations();
+    try {
+      filteredOps.initialize(filteredConfig, null, null);
+      NameIdentifier ident = tableIdent(schema, "hive_tbl");
+
+      assertEquals(0, filteredOps.listTables(tableNs(schema)).length);
+      assertThrows(NoSuchTableException.class, () -> 
filteredOps.loadTable(ident));
+      assertThrows(
+          NoSuchTableException.class,
+          () -> filteredOps.alterTable(ident, TableChange.setProperty("k", 
"v")));
+      assertFalse(filteredOps.dropTable(ident));
+      assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              filteredOps.createTable(
+                  tableIdent(schema, "hidden_tbl"),
+                  hiveColumns(),
+                  null,
+                  hiveTableProps(),
+                  new Transform[0],
+                  Distributions.NONE,
+                  new SortOrder[0],
+                  new Index[0]));
+    } finally {
+      filteredOps.close();
+    }
+
+    // The table is untouched and still visible through the unfiltered catalog.
+    Table table = ops.loadTable(tableIdent(schema, "hive_tbl"));
+    assertFalse(table.properties().containsKey("k"));
+    assertThrows(NoSuchTableException.class, () -> 
ops.loadTable(tableIdent(schema, "hidden_tbl")));
+  }
+
   @Test
   void testDropSchemaNonEmpty() {
     String schema = newSchema();
@@ -465,6 +511,16 @@ abstract class AbstractGlueCatalogIT {
     assertTrue(names.contains("dt=2024-01-01"));
   }
 
+  @Test
+  void testAddPartitionRejectsEmptyValue() {
+    String schema = newSchema();
+    ops.createSchema(schemaIdent(schema), null, Collections.emptyMap());
+    SupportsPartitions sp = createPartitionedTable(schema, "ptbl");
+
+    assertThrows(IllegalArgumentException.class, () -> 
sp.addPartition(identityPartition("")));
+    assertEquals(0, sp.listPartitionNames().length);
+  }
+
   @Test
   void testListPartitions() {
     String schema = newSchema();
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/MotoGlueCatalogIT.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/MotoGlueCatalogIT.java
index 8fcc8a5d5f..b7bddba9d7 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/MotoGlueCatalogIT.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/integration/test/MotoGlueCatalogIT.java
@@ -18,12 +18,23 @@
  */
 package org.apache.gravitino.catalog.glue.integration.test;
 
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.catalog.glue.GlueCatalogOperations;
 import org.apache.gravitino.catalog.glue.GlueConstants;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
 import org.apache.gravitino.integration.test.container.GravitinoMotoContainer;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
 
 /**
@@ -60,6 +71,53 @@ class MotoGlueCatalogIT extends AbstractGlueCatalogIT {
     }
   }
 
+  @Test
+  void testUnreachableEndpointIsConnectionFailure() throws IOException {
+    int closedPort;
+    try (ServerSocket socket = new ServerSocket(0)) {
+      closedPort = socket.getLocalPort();
+    }
+    String endpoint = "http://127.0.0.1:"; + closedPort;
+
+    ConnectionFailedException e =
+        assertThrows(ConnectionFailedException.class, () -> 
listSchemasAt(endpoint, Map.of()));
+    assertTrue(e.getMessage().contains(endpoint), e.getMessage());
+  }
+
+  @Test
+  void testEndpointThatDoesNotAnswerTimesOut() throws IOException {
+    // The socket accepts connections but never answers, like a stalled 
endpoint.
+    try (ServerSocket silent = new ServerSocket(0)) {
+      String endpoint = "http://127.0.0.1:"; + silent.getLocalPort();
+      Map<String, String> timeouts =
+          Map.of(
+              GlueConstants.AWS_GLUE_API_CALL_TIMEOUT_MS, "3000",
+              GlueConstants.AWS_GLUE_API_CALL_ATTEMPT_TIMEOUT_MS, "1000",
+              GlueConstants.AWS_GLUE_MAX_ATTEMPTS, "2");
+
+      long start = System.nanoTime();
+      ConnectionFailedException e =
+          assertThrows(ConnectionFailedException.class, () -> 
listSchemasAt(endpoint, timeouts));
+      long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - 
start);
+
+      assertTrue(e.getMessage().contains("timed out"), e.getMessage());
+      assertTrue(elapsedMs < 10_000, "took " + elapsedMs + " ms");
+    }
+  }
+
+  private void listSchemasAt(String endpoint, Map<String, String> extraConfig) 
throws IOException {
+    Map<String, String> config = new HashMap<>(catalogConfig());
+    config.put(GlueConstants.AWS_GLUE_ENDPOINT, endpoint);
+    config.putAll(extraConfig);
+    GlueCatalogOperations unreachableOps = new GlueCatalogOperations();
+    try {
+      unreachableOps.initialize(config, null, null);
+      unreachableOps.listSchemas(Namespace.of("ml", "cat"));
+    } finally {
+      unreachableOps.close();
+    }
+  }
+
   @Override
   protected Map<String, String> catalogConfig() {
     String endpoint =
diff --git a/docs/aws-glue-catalog.md b/docs/aws-glue-catalog.md
index a0ed1ff042..2cce47a845 100644
--- a/docs/aws-glue-catalog.md
+++ b/docs/aws-glue-catalog.md
@@ -35,16 +35,19 @@ The Glue catalog supports creating, updating, and deleting 
databases and tables
 
 Besides the [common catalog 
properties](./gravitino-server-config.md#catalog-properties-configuration), the 
Glue catalog has the following properties:
 
-| Property Name           | Description                                        
                                                                                
                                                                 | Default 
Value | Required | Immutable |
-|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|----------|-----------|
-| `aws-region`            | AWS region for the Glue Data Catalog (e.g. 
`us-east-1`).                                                                   
                                                                         | 
(none)        | Yes      | Yes       |
-| `aws-glue-catalog-id`   | The 12-digit AWS account ID that owns the Glue 
catalog. When omitted, defaults to the caller's AWS account ID.                 
                                                                     | (none)   
     | No       | Yes       |
-| `aws-access-key-id`     | AWS access key ID for static credential 
authentication. When omitted, the default credential chain is used.             
                                                                            | 
(none)        | No       | No        |
-| `aws-secret-access-key` | AWS secret access key paired with 
`aws-access-key-id`. When omitted, the default credential chain is used.        
                                                                                
  | (none)        | No       | No        |
-| `aws-glue-endpoint`     | Custom Glue endpoint URL for VPC endpoints or 
LocalStack testing (e.g. `http://localhost:4566`).                              
                                                                      | (none)  
      | No       | No        |
-| `warehouse`             | Base storage path used as the warehouse when no 
explicit `location` is specified at table creation time and the Glue database 
declares no `LocationUri` (e.g. `s3://my-bucket/warehouse`). Table location is 
then derived as `warehouse/database/table`. | (none)        | Yes      | No     
   |
-| `default-table-format`  | Default format for tables created via Gravitino's 
`createTable()` API. Accepted values: `iceberg`, `hive`.                        
                                                                  | `hive`      
  | No       | No        |
-| `table-format-filter`   | Comma-separated list of table formats exposed by 
`listTables()` and `loadTable()`. Accepted values: `all`, `hive`, `iceberg`, 
`delta`, `parquet`. Use to restrict visible table types.              | `all`   
      | No       | No        |
+| Property Name                          | Description                         
                                                                                
                                                                                
                                                                                
                                                                 | Default 
Value | Required | Immutable |
+|----------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|----------|-----------|
+| `aws-region`                           | AWS region for the Glue Data 
Catalog, as a known AWS region identifier in lower case (e.g. `us-east-1`).     
                                                                                
                                                                                
                                                                        | 
(none)        | Yes      | Yes       |
+| `aws-glue-catalog-id`                  | The 12-digit AWS account ID that 
owns the Glue catalog. When omitted, defaults to the caller's AWS account ID.   
                                                                                
                                                                                
                                                                    | (none)    
    | No       | Yes       |
+| `aws-access-key-id`                    | AWS access key ID for static 
credential authentication. When omitted, the default credential chain is used. 
Temporary keys (prefix `ASIA`) are not supported, because they need a session 
token.                                                                          
                                                                           | 
(none)        | No       | No        |
+| `aws-secret-access-key`                | AWS secret access key paired with 
`aws-access-key-id`. When omitted, the default credential chain is used.        
                                                                                
                                                                                
                                                                   | (none)     
   | No       | No        |
+| `aws-glue-endpoint`                    | Custom Glue endpoint URL for VPC 
endpoints or LocalStack testing (e.g. `http://localhost:4566`). Must be an 
`http` or `https` URL.                                                          
                                                                                
                                                                         | 
(none)        | No       | No        |
+| `aws-glue-api-call-timeout-ms`         | Upper bound in milliseconds for one 
Glue API call made by the catalog, including all retries. The AWS clients used 
for Iceberg tables only take `aws-glue-api-call-attempt-timeout-ms`.            
                                                                                
                                                                  | `30000`     
  | No       | No        |
+| `aws-glue-api-call-attempt-timeout-ms` | Upper bound in milliseconds for a 
single attempt of a Glue API call. Also used as the connection and socket 
timeout of the AWS clients (Glue and S3) used for Iceberg tables.               
                                                                                
                                                                         | 
`10000`       | No       | No        |
+| `aws-glue-max-attempts`                | Maximum number of attempts of a 
Glue API call made by the catalog, including the first one. Retries apply to 
throttling and transient failures. The AWS clients used for Iceberg tables keep 
the AWS SDK default.                                                            
                                                                        | `4`   
        | No       | No        |
+| `warehouse`                            | Base storage path used as the 
warehouse when no explicit `location` is specified at table creation time and 
the Glue database declares no `LocationUri` (e.g. `s3://my-bucket/warehouse`). 
Table location is then derived as `warehouse/database/table`. Must be a URI 
with a scheme. Iceberg tables need an `s3://`, `s3a://` or `s3n://` location. | 
(none)        | Yes      | No        |
+| `default-table-format`                 | Default format for tables created 
via Gravitino's `createTable()` API. Accepted values: `iceberg`, `hive`.        
                                                                                
                                                                                
                                                                   | `hive`     
   | No       | No        |
+| `table-format-filter`                  | Comma-separated list of table 
formats exposed by the catalog. Accepted values: `all`, `hive`, `iceberg`, 
`delta`, `parquet`. Tables of other formats are absent from list, load, alter 
and drop, and cannot be created through the catalog.                            
                                                                              | 
`all`         | No       | No        |
 
 :::note
 **Authentication priority**: Static credentials (`aws-access-key-id` + 
`aws-secret-access-key`) take precedence over the default credential chain 
(environment variables, instance profile, container credentials).
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
index c58559c303..2c8dcdcceb 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
@@ -289,6 +289,9 @@ public class ExceptionHandlers {
       } else if (e instanceof UnsupportedOperationException) {
         return Utils.unsupportedOperation(errorMsg, e);
 
+      } else if (e instanceof ForbiddenException) {
+        return Utils.forbidden(errorMsg, e);
+
       } else if (e instanceof NotInUseException) {
         return Utils.notInUse(errorMsg, e);
 
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestExceptionHandlers.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestExceptionHandlers.java
index 4856395f27..706202fadd 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestExceptionHandlers.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestExceptionHandlers.java
@@ -22,6 +22,7 @@ import java.util.List;
 import javax.ws.rs.core.Response;
 import org.apache.gravitino.dto.responses.ErrorConstants;
 import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.InUseException;
 import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
@@ -150,6 +151,21 @@ public class TestExceptionHandlers {
         ServiceUnavailableException.class.getSimpleName(), 
errorResponse.getType());
   }
 
+  @Test
+  void testPartitionForbiddenReturnsForbidden() {
+    Response response =
+        ExceptionHandlers.handlePartitionException(
+            OperationType.LIST,
+            "",
+            "table",
+            new ForbiddenException("not authorized to perform: 
glue:GetTable"));
+
+    Assertions.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
+    ErrorResponse errorResponse = (ErrorResponse) response.getEntity();
+    Assertions.assertEquals(ErrorConstants.FORBIDDEN_CODE, 
errorResponse.getCode());
+    Assertions.assertEquals(ForbiddenException.class.getSimpleName(), 
errorResponse.getType());
+  }
+
   @Test
   void testUnsupportedOperationReturnsNotImplemented() {
     UnsupportedOperationException exception =

Reply via email to