Skip to content

Commit 096faf5

Browse files
authored
Handle errors in the S3 bucket explorer (#2380)
- If any part threw, we would fail silently, catch exceptions and show error nodes - Allow loading more again to retry on errors, if the bucket/folder itself doesn't load the refresh button can be used
1 parent 59cdbe5 commit 096faf5

File tree

5 files changed

+213
-24
lines changed

5 files changed

+213
-24
lines changed

jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeNode.kt

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import com.intellij.openapi.fileTypes.FileTypeRegistry
88
import com.intellij.openapi.util.io.FileUtilRt
99
import com.intellij.ui.treeStructure.SimpleNode
1010
import kotlinx.coroutines.runBlocking
11+
import software.aws.toolkits.core.utils.error
12+
import software.aws.toolkits.core.utils.getLogger
13+
import software.aws.toolkits.jetbrains.core.utils.buildList
1114
import software.aws.toolkits.jetbrains.services.s3.NOT_VERSIONED_VERSION_ID
1215
import software.aws.toolkits.resources.message
1316
import java.time.Instant
@@ -61,8 +64,12 @@ abstract class S3LazyLoadParentNode<T>(bucket: S3VirtualBucket, parent: S3LazyLo
6164
return
6265
}
6366

64-
loadedPages.add(continuationMarker)
65-
cachedList = children.dropLastWhile { it is S3TreeContinuationNode<*> } + loadObjects(continuationMarker)
67+
val more = loadObjects(continuationMarker)
68+
// Only say it has loaded before if it loaded successfully
69+
if (more.none { it is S3TreeErrorNode || it is S3TreeErrorContinuationNode<*> }) {
70+
loadedPages.add(continuationMarker)
71+
}
72+
cachedList = children.dropLastWhile { it is S3TreeContinuationNode<*> || it is S3TreeErrorNode } + more
6673
}
6774
}
6875

@@ -77,26 +84,41 @@ class S3TreeDirectoryNode(bucket: S3VirtualBucket, parent: S3TreeDirectoryNode?,
7784
override fun directoryPath(): String = key
7885

7986
override fun loadObjects(continuationMarker: String?): List<S3TreeNode> {
80-
val response = runBlocking {
81-
bucket.listObjects(key, continuationMarker)
82-
}
83-
84-
val continuation = listOfNotNull(
85-
response.nextContinuationToken()?.let {
86-
S3TreeContinuationNode(bucket, this, this.key, it)
87+
try {
88+
val response = runBlocking {
89+
bucket.listObjects(key, continuationMarker)
8790
}
88-
)
89-
90-
val folders = response.commonPrefixes()?.map { S3TreeDirectoryNode(bucket, this, it.prefix()) } ?: emptyList()
9191

92-
val s3Objects = response
93-
.contents()
94-
?.filterNotNull()
95-
?.filterNot { it.key() == key }
96-
?.map { S3TreeObjectNode(this, it.key(), it.size(), it.lastModified()) as S3TreeNode }
97-
?: emptyList()
92+
val continuation = listOfNotNull(
93+
response.nextContinuationToken()?.let {
94+
S3TreeContinuationNode(bucket, this, this.key, it)
95+
}
96+
)
97+
98+
val folders = response.commonPrefixes()?.map { S3TreeDirectoryNode(bucket, this, it.prefix()) } ?: emptyList()
99+
100+
val s3Objects = response
101+
.contents()
102+
?.filterNotNull()
103+
?.filterNot { it.key() == key }
104+
?.map { S3TreeObjectNode(this, it.key(), it.size(), it.lastModified()) as S3TreeNode }
105+
?: emptyList()
106+
107+
return (folders + s3Objects).sortedBy { it.key } + continuation
108+
} catch (e: Exception) {
109+
LOG.error(e) { "Loading objects failed!" }
110+
return buildList {
111+
if (continuationMarker != null) {
112+
add(S3TreeErrorContinuationNode(bucket, this@S3TreeDirectoryNode, this@S3TreeDirectoryNode.key, continuationMarker))
113+
} else {
114+
add(S3TreeErrorNode(bucket, this@S3TreeDirectoryNode))
115+
}
116+
}
117+
}
118+
}
98119

99-
return (folders + s3Objects).sortedBy { it.key } + continuation
120+
companion object {
121+
private val LOG = getLogger<S3TreeDirectoryNode>()
100122
}
101123
}
102124

@@ -129,12 +151,16 @@ class S3TreeObjectNode(parent: S3TreeDirectoryNode, key: String, override val si
129151
override fun fileName() = key.substringAfterLast("/")
130152

131153
override fun loadObjects(continuationMarker: VersionContinuationToken?): List<S3TreeNode> {
132-
if (showHistory) {
154+
if (!showHistory) {
155+
return emptyList()
156+
}
157+
158+
try {
133159
val response = runBlocking {
134160
bucket.listObjectVersions(key, continuationMarker?.keyMarker, continuationMarker?.versionId)
135161
}
136162

137-
return mutableListOf<S3TreeNode>().apply {
163+
return buildList {
138164
response?.versions()
139165
?.filter { it.key() == key && it.versionId() != NOT_VERSIONED_VERSION_ID }
140166
?.map { S3TreeObjectVersionNode(this@S3TreeObjectNode, it.versionId(), it.size(), it.lastModified()) }
@@ -154,9 +180,27 @@ class S3TreeObjectNode(parent: S3TreeDirectoryNode, key: String, override val si
154180
)
155181
}
156182
}
183+
} catch (e: Exception) {
184+
LOG.error(e) { "Loading objects failed!" }
185+
return buildList {
186+
if (continuationMarker != null) {
187+
add(
188+
S3TreeErrorContinuationNode(
189+
bucket,
190+
this@S3TreeObjectNode,
191+
this@S3TreeObjectNode.key,
192+
continuationMarker
193+
)
194+
)
195+
} else {
196+
add(S3TreeErrorNode(bucket, this@S3TreeObjectNode))
197+
}
198+
}
157199
}
200+
}
158201

159-
return emptyList()
202+
companion object {
203+
private val LOG = getLogger<S3TreeObjectNode>()
160204
}
161205
}
162206

@@ -190,7 +234,7 @@ class S3TreeObjectVersionNode(parent: S3TreeObjectNode, override val versionId:
190234
override fun toString(): String = "S3TreeObjectVersionNode(key='$key', versionId='$versionId')"
191235
}
192236

193-
class S3TreeContinuationNode<T>(
237+
open class S3TreeContinuationNode<T>(
194238
bucket: S3VirtualBucket,
195239
private val parentNode: S3LazyLoadParentNode<T>,
196240
key: String,
@@ -208,3 +252,27 @@ class S3TreeContinuationNode<T>(
208252

209253
override fun getEqualityObjects(): Array<Any?> = arrayOf(bucket, key, continuationMarker)
210254
}
255+
256+
class S3TreeErrorContinuationNode<T>(
257+
bucket: S3VirtualBucket,
258+
parentNode: S3LazyLoadParentNode<T>,
259+
key: String,
260+
continuationMarker: T
261+
) : S3TreeContinuationNode<T>(bucket, parentNode, key, continuationMarker) {
262+
init {
263+
icon = AllIcons.General.Error
264+
}
265+
266+
override fun displayName(): String = message("s3.load_more_failed")
267+
}
268+
269+
class S3TreeErrorNode(
270+
bucket: S3VirtualBucket,
271+
parentNode: S3LazyLoadParentNode<*>
272+
) : S3TreeNode(bucket, parentNode, "${parentNode.key}error") {
273+
init {
274+
icon = AllIcons.General.Error
275+
}
276+
277+
override fun displayName(): String = message("s3.error_loading")
278+
}

jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyPathAction.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package software.aws.toolkits.jetbrains.services.s3.objectActions
55

66
import com.intellij.openapi.ide.CopyPasteManager
77
import com.intellij.openapi.project.Project
8+
import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeContinuationNode
9+
import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeErrorNode
810
import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode
911
import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectVersionNode
1012
import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable
@@ -18,5 +20,5 @@ class CopyPathAction(private val project: Project, treeTable: S3TreeTable) : Sin
1820
S3Telemetry.copyPath(project)
1921
}
2022

21-
override fun enabled(node: S3TreeNode): Boolean = node !is S3TreeObjectVersionNode
23+
override fun enabled(node: S3TreeNode): Boolean = node !is S3TreeObjectVersionNode && node !is S3TreeContinuationNode<*> && node !is S3TreeErrorNode
2224
}

jetbrains-core/tst/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeDirectoryNodeTest.kt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.intellij.testFramework.ProjectRule
88
import com.nhaarman.mockitokotlin2.any
99
import com.nhaarman.mockitokotlin2.argumentCaptor
1010
import com.nhaarman.mockitokotlin2.doReturn
11+
import com.nhaarman.mockitokotlin2.doThrow
1112
import com.nhaarman.mockitokotlin2.times
1213
import com.nhaarman.mockitokotlin2.verify
1314
import org.assertj.core.api.Assertions.assertThat
@@ -149,6 +150,61 @@ class S3TreeDirectoryNodeTest {
149150
assertThat(secondRequest.continuationToken()).isEqualTo("Token")
150151
}
151152

153+
@Test
154+
fun `get children fails shows error node`() {
155+
val s3Client = delegateMock<S3Client> {
156+
on { listObjectsV2(any<ListObjectsV2Request>()) } doThrow IllegalStateException("Bad!")
157+
}
158+
159+
val bucket = S3VirtualBucket(s3Bucket, s3Client)
160+
val sut = S3TreeDirectoryNode(bucket, null, "my/folder")
161+
162+
assertThat(sut.children).containsExactly(
163+
S3TreeErrorNode(bucket, sut)
164+
)
165+
}
166+
167+
@Test
168+
fun `load more fails once then succeeds`() {
169+
val s3Client = delegateMock<S3Client> {
170+
on { listObjectsV2(any<ListObjectsV2Request>()) }
171+
.thenReturn(
172+
ListObjectsV2Response.builder()
173+
.contents(createS3Object("my/folder/file.txt"))
174+
.nextContinuationToken("Token")
175+
.build()
176+
)
177+
.thenThrow(IllegalStateException("Bad!"))
178+
.thenReturn(
179+
ListObjectsV2Response.builder()
180+
.contents(createS3Object("my/folder/picture.png"))
181+
.build()
182+
)
183+
}
184+
185+
val bucket = S3VirtualBucket(s3Bucket, s3Client)
186+
val sut = S3TreeDirectoryNode(bucket, null, "my/folder/")
187+
188+
assertThat(sut.children).containsExactly(
189+
S3TreeObjectNode(sut, "my/folder/file.txt", objectSize, lastModifiedTime),
190+
S3TreeContinuationNode(bucket, sut, sut.key, "Token")
191+
)
192+
193+
(sut.children.last() as S3TreeContinuationNode<*>).loadMore()
194+
195+
assertThat(sut.children).containsExactly(
196+
S3TreeObjectNode(sut, "my/folder/file.txt", objectSize, lastModifiedTime),
197+
S3TreeErrorContinuationNode(bucket, sut, sut.key, "Token")
198+
)
199+
200+
(sut.children.last() as S3TreeContinuationNode<*>).loadMore()
201+
202+
assertThat(sut.children).containsExactly(
203+
S3TreeObjectNode(sut, "my/folder/file.txt", objectSize, lastModifiedTime),
204+
S3TreeObjectNode(sut, "my/folder/picture.png", objectSize, lastModifiedTime)
205+
)
206+
}
207+
152208
@Test
153209
fun `load more is idempotent`() {
154210
val requestCaptor = argumentCaptor<ListObjectsV2Request>()

jetbrains-core/tst/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeObjectNodeTest.kt

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.intellij.testFramework.ProjectRule
88
import com.nhaarman.mockitokotlin2.any
99
import com.nhaarman.mockitokotlin2.argumentCaptor
1010
import com.nhaarman.mockitokotlin2.doReturn
11+
import com.nhaarman.mockitokotlin2.doThrow
1112
import com.nhaarman.mockitokotlin2.times
1213
import com.nhaarman.mockitokotlin2.verify
1314
import org.assertj.core.api.Assertions.assertThat
@@ -175,6 +176,66 @@ class S3TreeObjectNodeTest {
175176
)
176177
}
177178

179+
@Test
180+
fun `get children fails`() {
181+
val s3Client = delegateMock<S3Client> {
182+
on { listObjectVersions(any<ListObjectVersionsRequest>()) } doThrow IllegalStateException("Network broke")
183+
}
184+
val bucket = S3VirtualBucket(s3Bucket, s3Client)
185+
val parent = S3TreeDirectoryNode(bucket, null, "my/folder/")
186+
val sut = S3TreeObjectNode(parent, "my/folder/picture.png", objectSize, lastModifiedTime).apply { showHistory = true }
187+
188+
assertThat(sut.children).containsExactly(
189+
S3TreeErrorNode(bucket, sut)
190+
)
191+
}
192+
193+
@Test
194+
fun `get children fails with pagination`() {
195+
val s3Client = delegateMock<S3Client> {
196+
on { listObjectVersions(any<ListObjectVersionsRequest>()) }
197+
.thenReturn(
198+
ListObjectVersionsResponse.builder()
199+
.versions(createS3ObjectVersion("my/folder/picture.png", "1"))
200+
.nextKeyMarker("KeyToken")
201+
.nextVersionIdMarker("VersionToken")
202+
.isTruncated(true)
203+
.build()
204+
)
205+
.thenThrow(IllegalStateException("Network broke"))
206+
.thenReturn(
207+
ListObjectVersionsResponse.builder()
208+
.versions(createS3ObjectVersion("my/folder/picture.png", "2"))
209+
.isTruncated(false)
210+
.build()
211+
)
212+
}
213+
214+
val bucket = S3VirtualBucket(s3Bucket, s3Client)
215+
val parent = S3TreeDirectoryNode(bucket, null, "my/folder/")
216+
val sut = S3TreeObjectNode(parent, "my/folder/picture.png", objectSize, lastModifiedTime)
217+
sut.showHistory = true
218+
219+
assertThat(sut.children).containsExactly(
220+
S3TreeObjectVersionNode(sut, "1", objectSize, lastModifiedTime),
221+
S3TreeContinuationNode(bucket, sut, sut.key, VersionContinuationToken("KeyToken", "VersionToken"))
222+
)
223+
224+
(sut.children.last() as S3TreeContinuationNode<*>).loadMore()
225+
226+
assertThat(sut.children).containsExactly(
227+
S3TreeObjectVersionNode(sut, "1", objectSize, lastModifiedTime),
228+
S3TreeErrorContinuationNode(bucket, sut, sut.key, VersionContinuationToken("KeyToken", "VersionToken"))
229+
)
230+
231+
(sut.children.last() as S3TreeContinuationNode<*>).loadMore()
232+
233+
assertThat(sut.children).containsExactly(
234+
S3TreeObjectVersionNode(sut, "1", objectSize, lastModifiedTime),
235+
S3TreeObjectVersionNode(sut, "2", objectSize, lastModifiedTime)
236+
)
237+
}
238+
178239
@Test
179240
fun `get children ignores versionId 'null'`() {
180241
val requestCaptor = argumentCaptor<ListObjectVersionsRequest>()

resources/resources/software/aws/toolkits/resources/localized_messages.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,8 +626,10 @@ s3.download.object.conflict.skip=Skip
626626
s3.download.object.conflict.skip_rest=Skip All Conflicts
627627
s3.download.object.failed=Failed to download object {0}
628628
s3.download.object.progress=Downloading ''{0}''
629+
s3.error_loading=Failed to load children
629630
s3.last_modified=Last Modified
630631
s3.load_more=load more...
632+
s3.load_more_failed=Failed to load more! load more...
631633
s3.name=Name
632634
s3.new.folder=New folder...
633635
s3.new.folder.name=Folder name:

0 commit comments

Comments
 (0)