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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-5607-5d74b610cf3c1990f7a70d3445dbdf2e6701f3a0
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 7b1c8dc7abca17465039aa5c043a302d3580b419
Author: Suyash Jain <[email protected]>
AuthorDate: Fri Jun 12 09:47:17 2026 -0700

    fix(file-service): apply LakeFS error handling to all call sites (#5607)
    
    ### What changes were proposed in this PR?
    
    #4177 introduced `LakeFSExceptionHandler.withLakeFSErrorHandling`, but
    only the multipart-upload and dataset-version paths used it. The
    remaining LakeFS call sites in `DatasetResource` either leaked raw
    `io.lakefs.clients.sdk.ApiException` to Dropwizard (an opaque 500 for
    the frontend) or caught `Exception` and rewrapped it as a generic 500,
    discarding the real LakeFS status code (401/403/404/409/...).
    
    ```
    Before:  LakeFS 404  ->  raw ApiException / catch(Exception)  ->  500 
"Failed to ..."
    After:   LakeFS 404  ->  withLakeFSErrorHandling              ->  404 
"Error while deleting file 'a.csv' ...: LakeFS resource not found. ..."
    ```
    
    Changes:
    
    | Change | Where |
    | --- | --- |
    | New overload `withLakeFSErrorHandling(operation: String)(call)` that
    prefixes the user-visible message with the failed operation |
    `LakeFSExceptionHandler.scala` |
    | 8 bare LakeFS calls now wrapped (size lookup, version listing, zip
    download, presigned URLs, cover image) | `DatasetResource.scala` |
    | 5 `catch Exception -> generic 500` blocks now use the handler;
    compensation logic (DB rollback on failed repo init, multipart abort) is
    preserved, and the abort-on-failure cleanup no longer masks the original
    error | `DatasetResource.scala` |
    
    Intentionally unchanged: best-effort cleanup sites that deliberately
    swallow errors, the per-dataset skip in `listDatasets`, and the
    `FileService` startup health check (failing fast at boot is correct
    there).
    
    ### Any related issues, documentation, discussions?
    
    Closes #4176
    
    ### How was this PR tested?
    
    New `LakeFSExceptionHandlerSpec` (7 unit cases): status-code mapping
    (400/401/403/404/409/4xx/5xx/unknown), operation context included in the
    frontend-visible message, success passthrough, and non-LakeFS exceptions
    propagating untouched.
    
    New integration case in `DatasetResourceSpec`: deleting a dataset whose
    LakeFS repository does not exist now yields `NotFoundException` (404)
    instead of a generic 500.
    
    ```
    sbt "FileService/testOnly 
org.apache.texera.service.util.LakeFSExceptionHandlerSpec"
    # Tests: succeeded 7, failed 0
    sbt "FileService/testOnly 
org.apache.texera.service.resource.DatasetResourceSpec"
    # Tests: succeeded 94, failed 0  (Testcontainers: LakeFS 1.51 + MinIO + 
Postgres)
    ```
    
    `sbt FileService/scalafixAll` and `sbt FileService/scalafmtAll` produce
    no further diff.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes, partially. I (Suyash Jain) worked on this PR together with Claude
    Code as a pair-programming assistant. I reviewed the final diff and ran
    the unit and Testcontainers-based integration suites locally before
    opening the PR.
    
    Generated-by: Claude Code (Claude Opus 4.7)
---
 .../texera/service/resource/DatasetResource.scala  | 150 ++++++++++++---------
 .../service/util/LakeFSExceptionHandler.scala      |  25 +++-
 .../org/apache/texera/service/MockLakeFS.scala     |  21 ++-
 .../service/resource/DatasetResourceSpec.scala     | 123 ++++++++++++++++-
 .../service/util/LakeFSExceptionHandlerSpec.scala  | 115 ++++++++++++++++
 5 files changed, 364 insertions(+), 70 deletions(-)

diff --git 
a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
 
b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
index 6da19a924f..1f8f28a85c 100644
--- 
a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
+++ 
b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
@@ -251,7 +251,9 @@ class DatasetResource extends LazyLogging {
       getOwner(ctx, did).getEmail,
       userAccessPrivilege,
       isOwner,
-      
LakeFSStorageClient.retrieveRepositorySize(targetDataset.getRepositoryName)
+      withLakeFSErrorHandling(s"retrieving the size of dataset 
'${targetDataset.getName}'") {
+        
LakeFSStorageClient.retrieveRepositorySize(targetDataset.getRepositoryName)
+      }
     )
   }
 
@@ -309,16 +311,23 @@ class DatasetResource extends LazyLogging {
       // Initialize the repository in LakeFS
       val repositoryName = s"dataset-${createdDataset.getDid}"
       try {
-        LakeFSStorageClient.initRepo(repositoryName)
+        withLakeFSErrorHandling(s"creating the repository of dataset 
'${dataset.getName}'") {
+          LakeFSStorageClient.initRepo(repositoryName)
+        }
       } catch {
         case e: Exception =>
+          // roll back the dataset record so a failed LakeFS init leaves no 
orphan row
           ctx
             .deleteFrom(DATASET)
             .where(DATASET.DID.eq(createdDataset.getDid))
             .execute()
-          throw new WebApplicationException(
-            s"Failed to create the dataset: ${e.getMessage}"
-          )
+          e match {
+            case web: WebApplicationException => throw web
+            case other =>
+              throw new WebApplicationException(
+                s"Failed to create the dataset: ${other.getMessage}"
+              )
+          }
       }
 
       // update repository name of the created dataset
@@ -444,14 +453,8 @@ class DatasetResource extends LazyLogging {
         // throw the exception that user has no access to certain dataset
         throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
       }
-      try {
+      withLakeFSErrorHandling(s"deleting the repository of dataset 
'${dataset.getName}'") {
         LakeFSStorageClient.deleteRepo(dataset.getRepositoryName)
-      } catch {
-        case e: Exception =>
-          throw new WebApplicationException(
-            s"Failed to delete a repository in LakeFS: ${e.getMessage}",
-            e
-          )
       }
       // delete the directory on S3
       if (
@@ -600,30 +603,39 @@ class DatasetResource extends LazyLogging {
         flush()
 
         // ---------- complete upload ----------
-        LakeFSStorageClient.completePresignedMultipartUploads(
-          repoName,
-          filePath,
-          uploadId,
-          completedParts.toList,
-          physicalAddress
-        )
+        withLakeFSErrorHandling(s"completing the multipart upload of file 
'$filePath'") {
+          LakeFSStorageClient.completePresignedMultipartUploads(
+            repoName,
+            filePath,
+            uploadId,
+            completedParts.toList,
+            physicalAddress
+          )
+        }
 
         Response.ok(Map("message" -> s"Uploaded $filePath in 
${completedParts.size} parts")).build()
       }
     } catch {
       case e: Exception =>
         if (repoName != null && filePath != null && uploadId != null && 
physicalAddress != null) {
-          LakeFSStorageClient.abortPresignedMultipartUploads(
-            repoName,
-            filePath,
-            uploadId,
-            physicalAddress
-          )
+          // best-effort cleanup; never let an abort failure mask the original 
error
+          try {
+            LakeFSStorageClient.abortPresignedMultipartUploads(
+              repoName,
+              filePath,
+              uploadId,
+              physicalAddress
+            )
+          } catch { case _: Throwable => () }
+        }
+        e match {
+          case web: WebApplicationException => throw web
+          case other =>
+            throw new WebApplicationException(
+              s"Failed to upload file to dataset: ${other.getMessage}",
+              other
+            )
         }
-        throw new WebApplicationException(
-          s"Failed to upload file to dataset: ${e.getMessage}",
-          e
-        )
     }
   }
 
@@ -693,14 +705,8 @@ class DatasetResource extends LazyLogging {
 
       // Decode the file path
       val filePath = URLDecoder.decode(encodedFilePath, 
StandardCharsets.UTF_8.name())
-      // Try to initialize the repository in LakeFS
-      try {
+      withLakeFSErrorHandling(s"deleting file '$filePath' from the dataset 
repository") {
         LakeFSStorageClient.deleteObject(repositoryName, filePath)
-      } catch {
-        case e: Exception =>
-          throw new WebApplicationException(
-            s"Failed to delete the file from repo in LakeFS: ${e.getMessage}"
-          )
       }
 
       Response.ok().build()
@@ -1042,14 +1048,8 @@ class DatasetResource extends LazyLogging {
 
       // Decode the file path
       val filePath = URLDecoder.decode(encodedFilePath, 
StandardCharsets.UTF_8.name())
-      // Try to reset the file change in LakeFS
-      try {
+      withLakeFSErrorHandling(s"resetting uncommitted changes of file 
'$filePath'") {
         LakeFSStorageClient.resetObjectUploadOrDeletion(repositoryName, 
filePath)
-      } catch {
-        case e: Exception =>
-          throw new WebApplicationException(
-            s"Failed to reset the changes from repo in LakeFS: ${e.getMessage}"
-          )
       }
       Response.ok().build()
     }
@@ -1255,7 +1255,11 @@ class DatasetResource extends LazyLogging {
       val datasetName = dataset.getName
       val repositoryName = dataset.getRepositoryName
       val versionHash = datasetVersion.getVersionHash
-      val objects = 
LakeFSStorageClient.retrieveObjectsOfVersion(repositoryName, versionHash)
+      val objects = withLakeFSErrorHandling(
+        s"listing files of version '$versionHash' of dataset '$datasetName'"
+      ) {
+        LakeFSStorageClient.retrieveObjectsOfVersion(repositoryName, 
versionHash)
+      }
 
       if (objects.isEmpty) {
         return Response
@@ -1271,7 +1275,9 @@ class DatasetResource extends LazyLogging {
           try {
             objects.foreach { obj =>
               val filePath = obj.getPath
-              val file = LakeFSStorageClient.getFileFromRepo(repositoryName, 
versionHash, filePath)
+              val file = withLakeFSErrorHandling(s"downloading file 
'$filePath' for the zip") {
+                LakeFSStorageClient.getFileFromRepo(repositoryName, 
versionHash, filePath)
+              }
 
               zipOut.putNextEntry(new ZipEntry(filePath))
               Files.copy(Paths.get(file.toURI), zipOut)
@@ -1432,11 +1438,15 @@ class DatasetResource extends LazyLogging {
         errorResponse
 
       case Right((resolvedRepositoryName, resolvedCommitHash, 
resolvedFilePath)) =>
-        val url = LakeFSStorageClient.getFilePresignedUrl(
-          resolvedRepositoryName,
-          resolvedCommitHash,
-          resolvedFilePath
-        )
+        val url = withLakeFSErrorHandling(
+          s"generating a presigned URL for file '$resolvedFilePath'"
+        ) {
+          LakeFSStorageClient.getFilePresignedUrl(
+            resolvedRepositoryName,
+            resolvedCommitHash,
+            resolvedFilePath
+          )
+        }
 
         Response.ok(Map("presignedUrl" -> url)).build()
     }
@@ -2125,11 +2135,13 @@ class DatasetResource extends LazyLogging {
         )
         .asInstanceOf[OnDataset]
 
-      val fileSize = LakeFSStorageClient.getFileSize(
-        document.getRepositoryName(),
-        document.getVersionHash(),
-        document.getFileRelativePath()
-      )
+      val fileSize = withLakeFSErrorHandling(s"reading the size of cover image 
'$normalized'") {
+        LakeFSStorageClient.getFileSize(
+          document.getRepositoryName(),
+          document.getVersionHash(),
+          document.getFileRelativePath()
+        )
+      }
 
       if (fileSize > COVER_IMAGE_SIZE_LIMIT_BYTES) {
         throw new BadRequestException(
@@ -2179,11 +2191,15 @@ class DatasetResource extends LazyLogging {
         .openReadonlyDocument(FileResolver.resolve(fullPath))
         .asInstanceOf[OnDataset]
 
-      val presignedUrl = LakeFSStorageClient.getFilePresignedUrl(
-        document.getRepositoryName(),
-        document.getVersionHash(),
-        document.getFileRelativePath()
-      )
+      val presignedUrl = withLakeFSErrorHandling(
+        s"generating a presigned URL for cover image '$coverImage'"
+      ) {
+        LakeFSStorageClient.getFilePresignedUrl(
+          document.getRepositoryName(),
+          document.getVersionHash(),
+          document.getFileRelativePath()
+        )
+      }
 
       Response.temporaryRedirect(new URI(presignedUrl)).build()
     }
@@ -2224,11 +2240,15 @@ class DatasetResource extends LazyLogging {
             .openReadonlyDocument(FileResolver.resolve(fullPath))
             .asInstanceOf[OnDataset]
 
-          val presignedUrl = LakeFSStorageClient.getFilePresignedUrl(
-            document.getRepositoryName(),
-            document.getVersionHash(),
-            document.getFileRelativePath()
-          )
+          val presignedUrl = withLakeFSErrorHandling(
+            s"generating a presigned URL for cover image '$coverImage'"
+          ) {
+            LakeFSStorageClient.getFilePresignedUrl(
+              document.getRepositoryName(),
+              document.getVersionHash(),
+              document.getFileRelativePath()
+            )
+          }
 
           Response.ok(Map("url" -> presignedUrl)).build()
       }
diff --git 
a/file-service/src/main/scala/org/apache/texera/service/util/LakeFSExceptionHandler.scala
 
b/file-service/src/main/scala/org/apache/texera/service/util/LakeFSExceptionHandler.scala
index c1997fb647..1894817c43 100644
--- 
a/file-service/src/main/scala/org/apache/texera/service/util/LakeFSExceptionHandler.scala
+++ 
b/file-service/src/main/scala/org/apache/texera/service/util/LakeFSExceptionHandler.scala
@@ -46,17 +46,36 @@ object LakeFSExceptionHandler {
     try {
       call
     } catch {
-      case e: io.lakefs.clients.sdk.ApiException => handleException(e)
+      case e: io.lakefs.clients.sdk.ApiException => handleException(e, None)
+    }
+  }
+
+  /**
+    * Wraps a LakeFS call with centralized error handling, prefixing the
+    * user-visible message with the operation that failed
+    * (e.g. "deleting file 'a.csv' from dataset 'd1'").
+    */
+  def withLakeFSErrorHandling[T](operation: String)(call: => T): T = {
+    try {
+      call
+    } catch {
+      case e: io.lakefs.clients.sdk.ApiException => handleException(e, 
Some(operation))
     }
   }
 
   /**
     * Converts LakeFS ApiException to appropriate HTTP exception
     */
-  private def handleException(e: io.lakefs.clients.sdk.ApiException): Nothing 
= {
+  private def handleException(
+      e: io.lakefs.clients.sdk.ApiException,
+      operation: Option[String]
+  ): Nothing = {
     val code = e.getCode
     val rawBody = Option(e.getResponseBody).filter(_.nonEmpty)
-    val message = s"${fallbackMessages(code)}"
+    val message = operation match {
+      case Some(op) => s"Error while $op: ${fallbackMessages(code)}"
+      case None     => fallbackMessages(code)
+    }
 
     logger.warn(s"LakeFS error $code, ${e.getMessage}, body: 
${rawBody.getOrElse("N/A")}")
 
diff --git 
a/file-service/src/test/scala/org/apache/texera/service/MockLakeFS.scala 
b/file-service/src/test/scala/org/apache/texera/service/MockLakeFS.scala
index 10c68bd085..62e60bd267 100644
--- a/file-service/src/test/scala/org/apache/texera/service/MockLakeFS.scala
+++ b/file-service/src/test/scala/org/apache/texera/service/MockLakeFS.scala
@@ -52,6 +52,16 @@ trait MockLakeFS extends ForAllTestContainer with 
BeforeAndAfterAll { self: Suit
     .createContainer()
   postgres.container.withNetwork(network)
 
+  // LakeFS bakes the pre-signed endpoint into its env before containers start,
+  // so MinIO cannot use a dynamically mapped host port: presigned URLs must be
+  // reachable from the host at an address known ahead of time. Reserve a free
+  // host port and pin MinIO's 9000 to it.
+  val minioHostPort: Int = {
+    val socket = new java.net.ServerSocket(0)
+    try socket.getLocalPort
+    finally socket.close()
+  }
+
   // MinIO for object storage
   val minio = MinIOContainer(
     dockerImageName = 
DockerImageName.parse("minio/minio:RELEASE.2025-02-28T09-55-16Z"),
@@ -59,6 +69,15 @@ trait MockLakeFS extends ForAllTestContainer with 
BeforeAndAfterAll { self: Suit
     password = "password"
   )
   minio.container.withNetwork(network)
+  minio.container.withCreateContainerCmdModifier { cmd =>
+    import com.github.dockerjava.api.model.{ExposedPort, PortBinding, Ports}
+    // setting explicit bindings replaces them all, so 9001 (console) must keep
+    // a dynamic binding or the container readiness check never passes
+    cmd.getHostConfig.withPortBindings(
+      new PortBinding(Ports.Binding.bindPort(minioHostPort), 
ExposedPort.tcp(9000)),
+      new PortBinding(Ports.Binding.empty(), ExposedPort.tcp(9001))
+    )
+  }
 
   // LakeFS
   val lakefsDatabaseURL: String =
@@ -80,7 +99,7 @@ trait MockLakeFS extends ForAllTestContainer with 
BeforeAndAfterAll { self: Suit
       "LAKEFS_BLOCKSTORE_TYPE" -> "s3",
       "LAKEFS_BLOCKSTORE_S3_FORCE_PATH_STYLE" -> "true",
       "LAKEFS_BLOCKSTORE_S3_ENDPOINT" -> 
s"http://${minio.container.getNetworkAliases.get(0)}:9000",
-      "LAKEFS_BLOCKSTORE_S3_PRE_SIGNED_ENDPOINT" -> "http://localhost:9000";,
+      "LAKEFS_BLOCKSTORE_S3_PRE_SIGNED_ENDPOINT" -> 
s"http://localhost:$minioHostPort";,
       "LAKEFS_BLOCKSTORE_S3_CREDENTIALS_ACCESS_KEY_ID" -> "texera_minio",
       "LAKEFS_BLOCKSTORE_S3_CREDENTIALS_SECRET_ACCESS_KEY" -> "password",
       "LAKEFS_AUTH_ENCRYPT_SECRET_KEY" -> "random_string_for_lakefs",
diff --git 
a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
 
b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
index eec7628a0b..1730d12a0a 100644
--- 
a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
+++ 
b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
@@ -51,7 +51,7 @@ import org.scalatest.tagobjects.Slow
 import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Tag}
 import org.slf4j.LoggerFactory
 
-import java.io.{ByteArrayInputStream, IOException, InputStream}
+import java.io.{ByteArrayInputStream, ByteArrayOutputStream, IOException, 
InputStream}
 import java.net.URLEncoder
 import java.nio.charset.StandardCharsets
 import java.nio.file.{Files, Paths}
@@ -380,6 +380,127 @@ class DatasetResourceSpec
     datasetDao.fetchOneByDid(dataset.getDid) should not be null
   }
 
+  it should "surface a LakeFS 404 as NotFoundException when deleting a dataset 
whose repo is missing" in {
+    val dataset = new Dataset
+    dataset.setName("delete-ds-no-repo")
+    dataset.setRepositoryName("delete-ds-no-repo")
+    dataset.setDescription("for lakefs 404 mapping test")
+    dataset.setOwnerUid(ownerUser.getUid)
+    dataset.setIsPublic(true)
+    dataset.setIsDownloadable(true)
+    datasetDao.insert(dataset)
+    // intentionally no LakeFSStorageClient.initRepo: the repository does not 
exist in LakeFS
+
+    val ex = intercept[NotFoundException] {
+      datasetResource.deleteDataset(dataset.getDid, sessionUser)
+    }
+    assertStatus(ex, 404)
+  }
+
+  "getDataset" should "return the dashboard dataset including its LakeFS 
repository size" in {
+    testDatasetVersion // ensures the LakeFS repo for baseDataset exists
+    val dashboardDataset = datasetResource.getDataset(baseDataset.getDid, 
sessionUser)
+    dashboardDataset.dataset.getDid shouldEqual baseDataset.getDid
+    dashboardDataset.size should be >= 0L
+  }
+
+  it should "surface a LakeFS 404 as NotFoundException when the dataset repo 
is missing" in {
+    val dataset = new Dataset
+    dataset.setName("get-ds-no-repo")
+    dataset.setRepositoryName("get-ds-no-repo")
+    dataset.setDescription("for lakefs 404 mapping test on getDataset")
+    dataset.setOwnerUid(ownerUser.getUid)
+    dataset.setIsPublic(true)
+    dataset.setIsDownloadable(true)
+    datasetDao.insert(dataset)
+    // intentionally no LakeFSStorageClient.initRepo: the repository does not 
exist in LakeFS
+
+    val ex = intercept[NotFoundException] {
+      datasetResource.getDataset(dataset.getDid, sessionUser)
+    }
+    assertStatus(ex, 404)
+  }
+
+  "uploadOneFileToDataset" should "stream a small file and complete the 
multipart upload" in {
+    testDatasetVersion // ensures the LakeFS repo for baseDataset exists
+    val payload = tinyBytes(0x5a, 2048)
+    val resp = datasetResource.uploadOneFileToDataset(
+      baseDataset.getDid,
+      urlEnc("upload-one-shot/sample.bin"),
+      "upload via single-file endpoint",
+      new ByteArrayInputStream(payload),
+      mkHeaders(payload.length.toLong),
+      sessionUser
+    )
+    resp.getStatus shouldEqual 200
+  }
+
+  it should "abort and wrap a mid-stream failure in a WebApplicationException" 
in {
+    testDatasetVersion
+    val payload = tinyBytes(0x33, 4096)
+    val ex = intercept[WebApplicationException] {
+      datasetResource.uploadOneFileToDataset(
+        baseDataset.getDid,
+        urlEnc("upload-one-shot/flaky.bin"),
+        "should fail mid-stream",
+        flakyStream(payload, failAfterBytes = 16),
+        mkHeaders(payload.length.toLong),
+        sessionUser
+      )
+    }
+    assertStatus(ex, 500)
+    ex.getMessage should include("Failed to upload file to dataset")
+  }
+
+  it should "rethrow WebApplicationExceptions unchanged when the user has no 
write access" in {
+    val ex = intercept[ForbiddenException] {
+      datasetResource.uploadOneFileToDataset(
+        multipartDataset.getDid,
+        urlEnc("upload-one-shot/forbidden.bin"),
+        "no write access",
+        new ByteArrayInputStream(tinyBytes(0x01)),
+        mkHeaders(1L),
+        multipartNoWriteSessionUser
+      )
+    }
+    assertStatus(ex, 403)
+  }
+
+  "getDatasetVersionZip" should "zip all files of a dataset version" in {
+    val version = testDatasetVersion
+    val resp =
+      datasetResource.getDatasetVersionZip(baseDataset.getDid, 
version.getDvid, null, sessionUser)
+    resp.getStatus shouldEqual 200
+    val out = new ByteArrayOutputStream()
+    resp.getEntity.asInstanceOf[StreamingOutput].write(out)
+    out.size() should be > 0
+  }
+
+  "getPresignedUrl" should "generate a presigned URL for an existing file" in {
+    testDatasetVersion
+    val resp = datasetResource.getPresignedUrl(
+      urlEnc("test-cover.jpg"),
+      baseDataset.getRepositoryName,
+      "main",
+      sessionUser
+    )
+    resp.getStatus shouldEqual 200
+    entityAsScalaMap(resp).get("presignedUrl") should not be None
+  }
+
+  it should "surface a LakeFS 404 as NotFoundException for a nonexistent file" 
in {
+    testDatasetVersion
+    val ex = intercept[NotFoundException] {
+      datasetResource.getPresignedUrl(
+        urlEnc("does-not-exist.bin"),
+        baseDataset.getRepositoryName,
+        "main",
+        sessionUser
+      )
+    }
+    assertStatus(ex, 404)
+  }
+
   "listDatasets" should "include a dataset whose LakeFS repo exists" in {
     val repoName = s"list-ok-${System.nanoTime()}"
     val dataset = new Dataset
diff --git 
a/file-service/src/test/scala/org/apache/texera/service/util/LakeFSExceptionHandlerSpec.scala
 
b/file-service/src/test/scala/org/apache/texera/service/util/LakeFSExceptionHandlerSpec.scala
new file mode 100644
index 0000000000..7f3b27cd60
--- /dev/null
+++ 
b/file-service/src/test/scala/org/apache/texera/service/util/LakeFSExceptionHandlerSpec.scala
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.service.util
+
+import io.lakefs.clients.sdk.ApiException
+import jakarta.ws.rs._
+import 
org.apache.texera.service.util.LakeFSExceptionHandler.withLakeFSErrorHandling
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class LakeFSExceptionHandlerSpec extends AnyFlatSpec with Matchers {
+
+  private def lakeFSError(code: Int): ApiException =
+    new ApiException(code, s"lakefs returned $code")
+
+  // typed as Unit (not Nothing) so the no-context overload resolves 
unambiguously
+  private def failingCall(code: Int): Unit = throw lakeFSError(code)
+
+  private def entityMessage(e: WebApplicationException): String =
+    e.getResponse.getEntity
+      .asInstanceOf[java.util.Map[String, String]]
+      .get("message")
+
+  "withLakeFSErrorHandling" should "return the call's result when no exception 
is thrown" in {
+    withLakeFSErrorHandling("reading a file")(42) shouldEqual 42
+  }
+
+  it should "map each LakeFS client error code to the matching JAX-RS 
exception" in {
+    val expected = Map(
+      400 -> classOf[BadRequestException],
+      401 -> classOf[NotAuthorizedException],
+      403 -> classOf[ForbiddenException],
+      404 -> classOf[NotFoundException]
+    )
+    expected.foreach {
+      case (code, exceptionClass) =>
+        val thrown = intercept[WebApplicationException] {
+          withLakeFSErrorHandling(failingCall(code))
+        }
+        thrown.getClass shouldEqual exceptionClass
+        thrown.getResponse.getStatus shouldEqual code
+    }
+  }
+
+  it should "keep the original status for other 4xx codes (e.g. 409 conflict)" 
in {
+    val thrown = intercept[WebApplicationException] {
+      withLakeFSErrorHandling(failingCall(409))
+    }
+    thrown.getResponse.getStatus shouldEqual 409
+  }
+
+  it should "map server-side and unknown codes to a 500 response" in {
+    Seq(500, 502, 503, 0).foreach { code =>
+      val thrown = intercept[InternalServerErrorException] {
+        withLakeFSErrorHandling(failingCall(code))
+      }
+      thrown.getResponse.getStatus shouldEqual 500
+    }
+  }
+
+  it should "include the operation context in the message visible to the 
frontend" in {
+    val thrown = intercept[NotFoundException] {
+      withLakeFSErrorHandling("deleting file 'a.csv' from dataset 'd1'") {
+        throw lakeFSError(404)
+      }
+    }
+    val message = entityMessage(thrown)
+    message should include("deleting file 'a.csv' from dataset 'd1'")
+    message should include("not found")
+  }
+
+  it should "produce a frontend-readable message without operation context 
too" in {
+    val thrown = intercept[ForbiddenException] {
+      withLakeFSErrorHandling(failingCall(403))
+    }
+    entityMessage(thrown) should include("Permission denied")
+  }
+
+  it should "let non-LakeFS exceptions propagate unchanged" in {
+    val original = new IllegalStateException("not a lakefs error")
+    val thrown = intercept[IllegalStateException] {
+      withLakeFSErrorHandling("any operation")(throw original)
+    }
+    thrown should be theSameInstanceAs original
+  }
+
+  it should "still map the status code when LakeFS provides a response body" 
in {
+    val withBody = new ApiException(
+      404,
+      java.util.Collections.emptyMap[String, java.util.List[String]](),
+      """{"message":"object not found"}"""
+    )
+    val thrown = intercept[NotFoundException] {
+      withLakeFSErrorHandling("reading file 'a.csv'")(throw withBody)
+    }
+    entityMessage(thrown) should include("LakeFS resource not found")
+  }
+}

Reply via email to