Skip to content

Commit 1757165

Browse files
authored
Throw any error from MinIO with ListObject APIs (#214)
* Throw any error from MinIO when listing objects * Capture ListObjectsAsync exceptions in VerifyObjectExistsAsync * Configure minio client timeout * Throw VerifyObjectsException on error * Convert MinIO exception with custom exceptions * Update API doc Signed-off-by: Victor Chang <[email protected]>
1 parent e7f640e commit 1757165

13 files changed

+600
-64
lines changed

src/Plugins/MinIO/ConfigurationKeys.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2021-2022 MONAI Consortium
2+
* Copyright 2021-2023 MONAI Consortium
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -29,6 +29,7 @@ internal static class ConfigurationKeys
2929
public static readonly string McExecutablePath = "executableLocation";
3030
public static readonly string McServiceName = "serviceName";
3131
public static readonly string CreateBuckets = "createBuckets";
32+
public static readonly string ApiCallTimeout = "timeout";
3233

3334
public static readonly string[] RequiredKeys = new[] { EndPoint, AccessKey, AccessToken, SecuredConnection, Region };
3435
public static readonly string[] McRequiredKeys = new[] { EndPoint, AccessKey, AccessToken, McExecutablePath, McServiceName };

src/Plugins/MinIO/LoggerMethods.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2022 MONAI Consortium
2+
* Copyright 2023 MONAI Consortium
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -15,6 +15,7 @@
1515
*/
1616

1717
using Microsoft.Extensions.Logging;
18+
using Minio.Exceptions;
1819

1920
namespace Monai.Deploy.Storage.MinIO
2021
{
@@ -43,5 +44,11 @@ public static partial class LoggerMethods
4344

4445
[LoggerMessage(EventId = 20007, Level = LogLevel.Information, Message = "Bucket {bucket} created in region {region}.")]
4546
public static partial void BucketCreated(this ILogger logger, string bucket, string region);
47+
48+
[LoggerMessage(EventId = 20008, Level = LogLevel.Error, Message = "Error connecting to MinIO.")]
49+
public static partial void ConnectionError(this ILogger logger, ConnectionException ex);
50+
51+
[LoggerMessage(EventId = 20009, Level = LogLevel.Error, Message = "Storage service error.")]
52+
public static partial void StorageServiceError(this ILogger logger, Exception ex);
4653
}
4754
}

src/Plugins/MinIO/MinIoClientFactory.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2021-2022 MONAI Consortium
2+
* Copyright 2021-2023 MONAI Consortium
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@ namespace Monai.Deploy.Storage.MinIO
2626
public class MinIoClientFactory : IMinIoClientFactory
2727
{
2828
private static readonly string DefaultClient = "_DEFAULT_";
29+
internal static readonly int DefaultTimeout = 2500;
2930
private readonly ConcurrentDictionary<string, MinioClient> _clients;
3031

3132
private StorageServiceConfiguration Options { get; }
@@ -112,10 +113,17 @@ private MinioClient CreateClient(string accessKey, string accessToken)
112113
{
113114
var endpoint = Options.Settings[ConfigurationKeys.EndPoint];
114115
var securedConnection = Options.Settings[ConfigurationKeys.SecuredConnection];
116+
var timeout = DefaultTimeout;
117+
118+
if (Options.Settings.ContainsKey(ConfigurationKeys.ApiCallTimeout) && !int.TryParse(Options.Settings[ConfigurationKeys.ApiCallTimeout], out timeout))
119+
{
120+
throw new ConfigurationException($"Invalid value specified for {ConfigurationKeys.ApiCallTimeout}: {Options.Settings[ConfigurationKeys.ApiCallTimeout]}");
121+
}
115122

116123
var client = new MinioClient()
117124
.WithEndpoint(endpoint)
118-
.WithCredentials(accessKey, accessToken);
125+
.WithCredentials(accessKey, accessToken)
126+
.WithTimeout(timeout);
119127

120128
if (bool.Parse(securedConnection))
121129
{

src/Plugins/MinIO/MinIoStorageService.cs

Lines changed: 147 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2021-2022 MONAI Consortium
2+
* Copyright 2021-2023 MONAI Consortium
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,10 +20,12 @@
2020
using Microsoft.Extensions.Logging;
2121
using Microsoft.Extensions.Options;
2222
using Minio;
23+
using Minio.Exceptions;
2324
using Monai.Deploy.Storage.API;
2425
using Monai.Deploy.Storage.Configuration;
2526
using Monai.Deploy.Storage.S3Policy;
2627
using Newtonsoft.Json;
28+
using ObjectNotFoundException = Minio.Exceptions.ObjectNotFoundException;
2729

2830
namespace Monai.Deploy.Storage.MinIO
2931
{
@@ -39,7 +41,7 @@ public class MinIoStorageService : IStorageService
3941
public MinIoStorageService(IMinIoClientFactory minioClientFactory, IAmazonSecurityTokenServiceClientFactory amazonSecurityTokenServiceClientFactory, IOptions<StorageServiceConfiguration> options, ILogger<MinIoStorageService> logger)
4042
{
4143
Guard.Against.Null(options);
42-
_minioClientFactory = minioClientFactory ?? throw new ArgumentNullException(nameof(IMinIoClientFactory));
44+
_minioClientFactory = minioClientFactory ?? throw new ArgumentNullException(nameof(minioClientFactory));
4345
_amazonSecurityTokenServiceClientFactory = amazonSecurityTokenServiceClientFactory ?? throw new ArgumentNullException(nameof(amazonSecurityTokenServiceClientFactory));
4446
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
4547

@@ -101,13 +103,14 @@ public async Task<Dictionary<string, bool>> VerifyObjectsExistAsync(string bucke
101103
Guard.Against.Null(artifactList);
102104

103105
var existingObjectsDict = new Dictionary<string, bool>();
106+
var exceptions = new List<Exception>();
104107

105108
foreach (var artifact in artifactList)
106109
{
107110
try
108111
{
109-
var fileObjects = await ListObjectsAsync(bucketName, artifact).ConfigureAwait(false);
110-
var folderObjects = await ListObjectsAsync(bucketName, artifact.EndsWith("/") ? artifact : $"{artifact}/", true).ConfigureAwait(false);
112+
var fileObjects = await ListObjectsAsync(bucketName, artifact, cancellationToken: cancellationToken).ConfigureAwait(false);
113+
var folderObjects = await ListObjectsAsync(bucketName, artifact.EndsWith("/") ? artifact : $"{artifact}/", true, cancellationToken).ConfigureAwait(false);
111114

112115
if (!folderObjects.Any() && !fileObjects.Any())
113116
{
@@ -122,10 +125,14 @@ public async Task<Dictionary<string, bool>> VerifyObjectsExistAsync(string bucke
122125
{
123126
_logger.VerifyObjectError(bucketName, e);
124127
existingObjectsDict.Add(artifact, false);
128+
exceptions.Add(e);
125129
}
126-
127130
}
128131

132+
if (exceptions.Any())
133+
{
134+
throw new VerifyObjectsException(exceptions, existingObjectsDict);
135+
}
129136
return existingObjectsDict;
130137
}
131138

@@ -134,17 +141,25 @@ public async Task<bool> VerifyObjectExistsAsync(string bucketName, string artifa
134141
Guard.Against.NullOrWhiteSpace(bucketName);
135142
Guard.Against.NullOrWhiteSpace(artifactName);
136143

137-
var fileObjects = await ListObjectsAsync(bucketName, artifactName).ConfigureAwait(false);
138-
var folderObjects = await ListObjectsAsync(bucketName, artifactName.EndsWith("/") ? artifactName : $"{artifactName}/", true).ConfigureAwait(false);
139-
140-
if (folderObjects.Any() || fileObjects.Any())
144+
try
141145
{
142-
return true;
143-
}
146+
var fileObjects = await ListObjectsAsync(bucketName, artifactName, cancellationToken: cancellationToken).ConfigureAwait(false);
147+
var folderObjects = await ListObjectsAsync(bucketName, artifactName.EndsWith("/") ? artifactName : $"{artifactName}/", true, cancellationToken).ConfigureAwait(false);
148+
149+
if (folderObjects.Any() || fileObjects.Any())
150+
{
151+
return true;
152+
}
144153

145-
_logger.FileNotFoundError(bucketName, $"{artifactName}");
154+
_logger.FileNotFoundError(bucketName, $"{artifactName}");
146155

147-
return false;
156+
return false;
157+
}
158+
catch (Exception ex)
159+
{
160+
_logger.VerifyObjectError(bucketName, ex);
161+
throw new VerifyObjectsException(ex.Message, ex);
162+
}
148163
}
149164

150165
public async Task PutObjectAsync(string bucketName, string objectName, Stream data, long size, string contentType, Dictionary<string, string>? metadata, CancellationToken cancellationToken = default)
@@ -295,36 +310,51 @@ public async Task CreateFolderWithCredentialsAsync(string bucketName, string fol
295310

296311
#region Internal Helper Methods
297312

298-
private static async Task CopyObjectUsingClient(IObjectOperations client, string sourceBucketName, string sourceObjectName, string destinationBucketName, string destinationObjectName, CancellationToken cancellationToken)
313+
private async Task CopyObjectUsingClient(IObjectOperations client, string sourceBucketName, string sourceObjectName, string destinationBucketName, string destinationObjectName, CancellationToken cancellationToken)
299314
{
300-
var copySourceObjectArgs = new CopySourceObjectArgs()
301-
.WithBucket(sourceBucketName)
302-
.WithObject(sourceObjectName);
303-
var copyObjectArgs = new CopyObjectArgs()
304-
.WithBucket(destinationBucketName)
305-
.WithObject(destinationObjectName)
306-
.WithCopyObjectSource(copySourceObjectArgs);
307-
await client.CopyObjectAsync(copyObjectArgs, cancellationToken).ConfigureAwait(false);
315+
await CallApi(async () =>
316+
{
317+
try
318+
{
319+
var copySourceObjectArgs = new CopySourceObjectArgs()
320+
.WithBucket(sourceBucketName)
321+
.WithObject(sourceObjectName);
322+
var copyObjectArgs = new CopyObjectArgs()
323+
.WithBucket(destinationBucketName)
324+
.WithObject(destinationObjectName)
325+
.WithCopyObjectSource(copySourceObjectArgs);
326+
await client.CopyObjectAsync(copyObjectArgs, cancellationToken).ConfigureAwait(false);
327+
}
328+
catch (ObjectNotFoundException ex) when (ex.ServerMessage.Contains("Not found", StringComparison.OrdinalIgnoreCase))
329+
{
330+
throw new API.StorageObjectNotFoundException(ex.ServerMessage);
331+
}
332+
}).ConfigureAwait(false);
308333
}
309334

310-
private static async Task GetObjectUsingClient(IObjectOperations client, string bucketName, string objectName, Action<Stream> callback, CancellationToken cancellationToken)
335+
private async Task GetObjectUsingClient(IObjectOperations client, string bucketName, string objectName, Action<Stream> callback, CancellationToken cancellationToken)
311336
{
312-
var args = new GetObjectArgs()
313-
.WithBucket(bucketName)
314-
.WithObject(objectName)
315-
.WithCallbackStream(callback);
316-
await client.GetObjectAsync(args, cancellationToken).ConfigureAwait(false);
337+
await CallApi(async () =>
338+
{
339+
var args = new GetObjectArgs()
340+
.WithBucket(bucketName)
341+
.WithObject(objectName)
342+
.WithCallbackStream(callback);
343+
await client.GetObjectAsync(args, cancellationToken).ConfigureAwait(false);
344+
}).ConfigureAwait(false);
317345
}
318346

319-
private async Task<IList<VirtualFileInfo>> ListObjectsUsingClient(IBucketOperations client, string bucketName, string? prefix, bool recursive, CancellationToken cancellationToken)
347+
private Task<IList<VirtualFileInfo>> ListObjectsUsingClient(IBucketOperations client, string bucketName, string? prefix, bool recursive, CancellationToken cancellationToken)
320348
{
321-
return await Task.Run(() =>
349+
var files = new List<VirtualFileInfo>();
350+
var listArgs = new ListObjectsArgs()
351+
.WithBucket(bucketName)
352+
.WithPrefix(prefix)
353+
.WithRecursive(recursive);
354+
355+
try
322356
{
323-
var files = new List<VirtualFileInfo>();
324-
var listArgs = new ListObjectsArgs()
325-
.WithBucket(bucketName)
326-
.WithPrefix(prefix)
327-
.WithRecursive(recursive);
357+
var done = new TaskCompletionSource<IList<VirtualFileInfo>>();
328358

329359
var objservable = client.ListObjectsAsync(listArgs, cancellationToken);
330360
var completedEvent = new ManualResetEventSlim(false);
@@ -341,44 +371,103 @@ private async Task<IList<VirtualFileInfo>> ListObjectsUsingClient(IBucketOperati
341371
error =>
342372
{
343373
_logger.ListObjectError(bucketName, error.Message);
374+
if (error is OperationCanceledException)
375+
done.SetException(error);
376+
else
377+
done.SetException(new ListObjectException(error.ToString()));
344378
},
345-
() => completedEvent.Set(), cancellationToken);
379+
() =>
380+
{
381+
done.SetResult(files);
382+
if (cancellationToken.IsCancellationRequested)
383+
{
384+
throw new ListObjectTimeoutException("Timed out waiting for results.");
385+
}
386+
}, cancellationToken);
346387

347-
completedEvent.Wait(cancellationToken);
348-
return files;
349-
}).ConfigureAwait(false);
388+
return done.Task;
389+
}
390+
catch (ConnectionException ex)
391+
{
392+
_logger.ConnectionError(ex);
393+
var iex = new StorageConnectionException(ex.Message);
394+
iex.Errors.Add(ex.ServerMessage);
395+
if (ex.ServerResponse is not null && !string.IsNullOrWhiteSpace(ex.ServerResponse.ErrorMessage))
396+
{
397+
iex.Errors.Add(ex.ServerResponse.ErrorMessage);
398+
}
399+
throw iex;
400+
}
401+
catch (Exception ex) when (ex is not ListObjectTimeoutException && ex is not ListObjectException)
402+
{
403+
_logger.StorageServiceError(ex);
404+
throw new StorageServiceException(ex.ToString());
405+
}
350406
}
351407

352-
private static async Task RemoveObjectUsingClient(IObjectOperations client, string bucketName, string objectName, CancellationToken cancellationToken)
408+
private async Task RemoveObjectUsingClient(IObjectOperations client, string bucketName, string objectName, CancellationToken cancellationToken)
353409
{
354-
var args = new RemoveObjectArgs()
410+
await CallApi(async () =>
411+
{
412+
var args = new RemoveObjectArgs()
355413
.WithBucket(bucketName)
356414
.WithObject(objectName);
357-
await client.RemoveObjectAsync(args, cancellationToken).ConfigureAwait(false);
415+
await client.RemoveObjectAsync(args, cancellationToken).ConfigureAwait(false);
416+
}).ConfigureAwait(false);
358417
}
359418

360-
private static async Task PutObjectUsingClient(IObjectOperations client, string bucketName, string objectName, Stream data, long size, string contentType, Dictionary<string, string>? metadata, CancellationToken cancellationToken)
419+
private async Task PutObjectUsingClient(IObjectOperations client, string bucketName, string objectName, Stream data, long size, string contentType, Dictionary<string, string>? metadata, CancellationToken cancellationToken)
361420
{
362-
var args = new PutObjectArgs()
363-
.WithBucket(bucketName)
364-
.WithObject(objectName)
365-
.WithStreamData(data)
366-
.WithObjectSize(size)
367-
.WithContentType(contentType);
368-
if (metadata is not null)
421+
await CallApi(async () =>
369422
{
370-
args.WithHeaders(metadata);
371-
}
423+
var args = new PutObjectArgs()
424+
.WithBucket(bucketName)
425+
.WithObject(objectName)
426+
.WithStreamData(data)
427+
.WithObjectSize(size)
428+
.WithContentType(contentType);
429+
if (metadata is not null)
430+
{
431+
args.WithHeaders(metadata);
432+
}
433+
434+
await client.PutObjectAsync(args, cancellationToken).ConfigureAwait(false);
435+
}).ConfigureAwait(false);
436+
}
372437

373-
await client.PutObjectAsync(args, cancellationToken).ConfigureAwait(false);
438+
private async Task RemoveObjectsUsingClient(IObjectOperations client, string bucketName, IEnumerable<string> objectNames, CancellationToken cancellationToken)
439+
{
440+
await CallApi(async () =>
441+
{
442+
var args = new RemoveObjectsArgs()
443+
.WithBucket(bucketName)
444+
.WithObjects(objectNames.ToList());
445+
await client.RemoveObjectsAsync(args, cancellationToken).ConfigureAwait(false);
446+
}).ConfigureAwait(false);
374447
}
375448

376-
private static async Task RemoveObjectsUsingClient(IObjectOperations client, string bucketName, IEnumerable<string> objectNames, CancellationToken cancellationToken)
449+
private async Task CallApi(Func<Task> func)
377450
{
378-
var args = new RemoveObjectsArgs()
379-
.WithBucket(bucketName)
380-
.WithObjects(objectNames.ToList());
381-
await client.RemoveObjectsAsync(args, cancellationToken).ConfigureAwait(false);
451+
try
452+
{
453+
await func().ConfigureAwait(false);
454+
}
455+
catch (ConnectionException ex)
456+
{
457+
_logger.ConnectionError(ex);
458+
var iex = new StorageConnectionException(ex.Message);
459+
iex.Errors.Add(ex.ServerMessage);
460+
if (ex.ServerResponse is not null && !string.IsNullOrWhiteSpace(ex.ServerResponse.ErrorMessage))
461+
{
462+
iex.Errors.Add(ex.ServerResponse.ErrorMessage);
463+
}
464+
throw iex;
465+
}
466+
catch (Exception ex)
467+
{
468+
_logger.StorageServiceError(ex);
469+
throw new StorageServiceException(ex.ToString());
470+
}
382471
}
383472

384473
#endregion Internal Helper Methods

src/Plugins/MinIO/Tests/Unit/MinIoHealthCheckTest.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2022 MONAI Consortium
2+
* Copyright 2022-2023 MONAI Consortium
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,7 +16,6 @@
1616

1717
using Microsoft.Extensions.Diagnostics.HealthChecks;
1818
using Microsoft.Extensions.Logging;
19-
using Minio;
2019
using Moq;
2120
using Xunit;
2221

0 commit comments

Comments
 (0)