Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Mindee.Cli/Commands/V2/SearchModelsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ public class Handler(V2Client mindeeClientV2)
/// <returns></returns>
public async Task<int> InvokeAsync(string? name, string? modelType, bool raw)
{
var response = await mindeeClientV2.SearchModels(name, modelType);
var response = await mindeeClientV2.SearchModelsAsync(name, modelType);
PrintToConsole(Console.Out, raw, response);
return 0;
}
Expand Down
69 changes: 69 additions & 0 deletions src/Mindee/V1/Exceptions/MindeeHttpExceptionV1Typed.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text.Json.Nodes;
using Mindee.V1.Parsing.Common;

namespace Mindee.V1.Exceptions
{
/// <summary>Bad Request (400).</summary>
public sealed class MindeeHttp400ExceptionV1 : MindeeHttpExceptionV1
{
/// <inheritdoc />
public MindeeHttp400ExceptionV1(string name, string message, ErrorDetails details)
: base(name, message, details, 400) { }
}

/// <summary>Unauthorized — invalid or missing API key (401).</summary>
public sealed class MindeeHttp401ExceptionV1 : MindeeHttpExceptionV1
{
/// <inheritdoc />
public MindeeHttp401ExceptionV1(string name, string message, ErrorDetails details)
: base(name, message, details, 401) { }
}

/// <summary>Forbidden — API key lacks permission (403).</summary>
public sealed class MindeeHttp403ExceptionV1 : MindeeHttpExceptionV1
{
/// <inheritdoc />
public MindeeHttp403ExceptionV1(string name, string message, ErrorDetails details)
: base(name, message, details, 403) { }
}

/// <summary>Not Found (404).</summary>
public sealed class MindeeHttp404ExceptionV1 : MindeeHttpExceptionV1
{
/// <inheritdoc />
public MindeeHttp404ExceptionV1(string name, string message, ErrorDetails details)
: base(name, message, details, 404) { }
}

/// <summary>Document too large (413).</summary>
public sealed class MindeeHttp413ExceptionV1 : MindeeHttpExceptionV1
{
/// <inheritdoc />
public MindeeHttp413ExceptionV1(string name, string message, ErrorDetails details)
: base(name, message, details, 413) { }
}

/// <summary>Too Many Requests — rate limit exceeded (429).</summary>
public sealed class MindeeHttp429ExceptionV1 : MindeeHttpExceptionV1
{
/// <inheritdoc />
public MindeeHttp429ExceptionV1(string name, string message, ErrorDetails details)
: base(name, message, details, 429) { }
}

/// <summary>Internal Server Error (500).</summary>
public sealed class MindeeHttp500ExceptionV1 : MindeeHttpExceptionV1
{
/// <inheritdoc />
public MindeeHttp500ExceptionV1(string name, string message, ErrorDetails details)
: base(name, message, details, 500) { }
}

/// <summary>Gateway Timeout / Request Timeout (504).</summary>
public sealed class MindeeHttp504ExceptionV1 : MindeeHttpExceptionV1
{
/// <inheritdoc />
public MindeeHttp504ExceptionV1(string name, string message, ErrorDetails details)
: base(name, message, details, 504) { }
}
}
70 changes: 55 additions & 15 deletions src/Mindee/V1/Http/MindeeApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,33 +260,73 @@ private TModel ResponseHandler<TModel>(RestResponse restResponse)
}
}

// Always add the status code, this REALLY should be made a property of our HTTP error classes.
errorMessage += $"HTTP code {restResponse.StatusCode}: ";
var statusCode = (int)restResponse.StatusCode;
errorMessage += $"HTTP code {statusCode}: ";

// If the server JSON return is empty, try to dump the raw contents.
// If that STILL doesn't work, notify the user that the server response is empty.
var apiErrorMessage = model?.ApiRequest.Error.ToString();
string errorName;
ErrorDetails errorDetails;

if (!string.IsNullOrEmpty(apiErrorMessage))
{
// JSON error body parsed successfully
errorMessage += apiErrorMessage;
}
else if (!string.IsNullOrEmpty(restResponse.Content))
{
errorMessage += restResponse.Content;
errorName = model.ApiRequest.Error.Code ?? "MindeeHttpError";
errorDetails = model.ApiRequest.Error.Details;
}
else
{
errorMessage += "Empty response from server.";
// Non-JSON (likely HTML) — apply substring heuristics
var body = restResponse.Content ?? string.Empty;
errorName = ClassifyHtmlError(body);
errorMessage += string.IsNullOrEmpty(body) ? "Empty response from server." : body;
errorDetails = null;
}

_logger?.LogError("{ErrorMessage}", errorMessage);

throw new MindeeHttpExceptionV1(
"MindeeHttpError",
errorMessage,
model?.ApiRequest.Error.Details,
(int)restResponse.StatusCode
);
throw BuildHttpException(errorName, errorMessage, errorDetails, statusCode);
}

/// <summary>
/// Classifies a non-JSON (HTML) error body into a meaningful error-name string,
/// matching the heuristics used by sibling SDKs (Python / Node).
/// </summary>
private static string ClassifyHtmlError(string body)
{
if (body.Contains("Maximum pdf pages", StringComparison.OrdinalIgnoreCase))
return "TooManyPages";
if (body.Contains("Max file size is", StringComparison.OrdinalIgnoreCase))
return "FileTooLarge";
if (body.Contains("Invalid file type", StringComparison.OrdinalIgnoreCase))
return "InvalidFiletype";
if (body.Contains("Gateway timeout", StringComparison.OrdinalIgnoreCase))
return "RequestTimeout";
if (body.Contains("Bad gateway", StringComparison.OrdinalIgnoreCase))
return "BadRequest";
if (body.Contains("Too Many Requests", StringComparison.OrdinalIgnoreCase))
return "TooManyRequests";
return "UnknownError";
}

/// <summary>
/// Constructs the most specific typed exception for the given HTTP status code.
/// </summary>
private static MindeeHttpExceptionV1 BuildHttpException(
string name, string message, ErrorDetails details, int statusCode)
{
return statusCode switch
{
400 => new MindeeHttp400ExceptionV1(name, message, details),
401 => new MindeeHttp401ExceptionV1(name, message, details),
403 => new MindeeHttp403ExceptionV1(name, message, details),
404 => new MindeeHttp404ExceptionV1(name, message, details),
413 => new MindeeHttp413ExceptionV1(name, message, details),
429 => new MindeeHttp429ExceptionV1(name, message, details),
500 => new MindeeHttp500ExceptionV1(name, message, details),
504 => new MindeeHttp504ExceptionV1(name, message, details),
_ => new MindeeHttpExceptionV1(name, message, details, statusCode),
};
}
}
}
19 changes: 17 additions & 2 deletions src/Mindee/V2/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,24 @@ InputSource inputSource
/// <param name="modelType">Model type filter.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns></returns>
public async Task<SearchResponse> SearchModels(string name = null, string modelType = null, CancellationToken ct = default)
public async Task<SearchResponse> SearchModelsAsync(string name = null, string modelType = null, CancellationToken ct = default)
{
return await _mindeeApi.SearchModels(name, modelType, ct);
return await _mindeeApi.SearchModelsAsync(name, modelType, ct);
}

/// <summary>
/// Searches for models.
/// (Deprecated: Please use <see cref="SearchModelsAsync"/> instead.)
/// </summary>
/// <param name="name"></param>
/// <param name="modelType"></param>
/// <param name="ct"></param>
/// <returns></returns>
[Obsolete("Please use SearchModelsAsync instead.")]
public async Task<SearchResponse> SearchModels(string name = null, string modelType = null,
CancellationToken ct = default)
{
return await SearchModelsAsync(name, modelType, ct);
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Mindee/V2/Http/HttpApiV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public abstract class HttpApiV2
/// <param name="modelType">Type of the model to search for.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns></returns>
public abstract Task<SearchResponse> SearchModels(string? name, string? modelType, CancellationToken ct = default);
public abstract Task<SearchResponse> SearchModelsAsync(string? name, string? modelType, CancellationToken ct = default);

/// <summary>
/// Get the error from the server return.
Expand Down
2 changes: 1 addition & 1 deletion src/Mindee/V2/Http/MindeeApiV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public override async Task<JobResponse> ReqPostEnqueueAsync(
return HandleJobResponse(response);
}

public override async Task<SearchResponse> SearchModels(string name, string modelType, CancellationToken ct = default)
public override async Task<SearchResponse> SearchModelsAsync(string name, string modelType, CancellationToken ct = default)
{
var request = new RestRequest("v2/search/models");
Logger?.LogInformation("Fetching models...");
Expand Down
12 changes: 6 additions & 6 deletions tests/Mindee.IntegrationTests/V1/ClientTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public async Task Parse_Url_Standard_SinglePage_MustSucceed()
public async Task Parse_Url_Standard_InvalidUrl_MustFail()
{
var inputSource = new UrlInputSource("https://bad-domain.test/invalid-file.ext");
await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() => _client.ParseAsync<ReceiptV5>(inputSource));
await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() => _client.ParseAsync<ReceiptV5>(inputSource));
}

[Fact(Timeout = 180000)]
Expand Down Expand Up @@ -154,7 +154,7 @@ public async Task Parse_File_Standard_AllWords_And_Cropper_MustSucceed()
public async Task Enqueue_File_Standard_SyncOnly_Async_MustFail()
{
var inputSource = new LocalInputSource(Constants.V1ProductDir + "passport/default_sample.jpg");
await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() => _client.EnqueueAsync<CropperV1>(inputSource));
await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() => _client.EnqueueAsync<CropperV1>(inputSource));
}

[Fact(Timeout = 180000)]
Expand All @@ -180,7 +180,7 @@ public async Task Enqueue_File_Standard_AsyncOnly_Async_MustSucceed()
public async Task Enqueue_File_Standard_AsyncOnly_Sync_MustFail()
{
var inputSource = new LocalInputSource(Constants.V1ProductDir + "invoice_splitter/default_sample.pdf");
await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() =>
await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() =>
_client.ParseAsync<InvoiceSplitterV1>(inputSource));
}

Expand Down Expand Up @@ -264,7 +264,7 @@ public async Task EnqueueAndParse_File_Standard_AsyncOnly_Async_UrlSource_Custom
public async Task ParseQueued_Standard_InvalidJob_MustFail()
{
var jobId = RandomString(15);
await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() =>
await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() =>
_client.ParseQueuedAsync<InvoiceSplitterV1>(jobId));
}

Expand All @@ -273,7 +273,7 @@ public async Task Enqueue_File_Generated_AsyncOnly_Sync_MustFail()
{
var endpoint = new CustomEndpoint("international_id", "mindee", "2");
var inputSource = new LocalInputSource(Constants.V1ProductDir + "international_id/default_sample.jpg");
await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() =>
await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() =>
_client.ParseAsync<GeneratedV1>(inputSource, endpoint));
}

Expand Down Expand Up @@ -308,7 +308,7 @@ public async Task ParseQueued_Generated_InvalidJob_MustFail()
{
var jobId = RandomString(15);
var endpoint = new CustomEndpoint("international_id", "mindee", "2");
await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() =>
await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() =>
_client.ParseQueuedAsync<GeneratedV1>(endpoint, jobId));
}

Expand Down
1 change: 0 additions & 1 deletion tests/Mindee.IntegrationTests/V2/ClientTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,6 @@ public async Task EnqueueAndGetResultAsync_CancelDuringPoll_ThrowsOperationCance
Constants.RootDir + "file_types/pdf/multipage_cut-1.pdf");
var inferenceParams = new ExtractionParameters(_findocModelId);

// Long initial delay so the cancellation fires before any poll attempt
var pollingOptions = new PollingOptions(initialDelaySec: 60, intervalSec: 10, maxRetries: 5);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));

Expand Down
20 changes: 13 additions & 7 deletions tests/Mindee.UnitTests/V1/Http/MindeeApiTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ public async Task Predict_WithWrongKey()

var mindeeApi = serviceProvider.GetRequiredService<MindeeApi>();

await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() => _ = mindeeApi.PredictPostAsync<ReceiptV4>(
var ex = await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() => _ = mindeeApi.PredictPostAsync<ReceiptV4>(
UnitTestBase.GetFakePredictParameter()
)
);
Assert.IsType<MindeeHttp400ExceptionV1>(ex);
}

[Fact]
Expand Down Expand Up @@ -61,9 +62,10 @@ public async Task Predict_WithErrorResponse()
);
var mindeeApi = serviceProvider.GetRequiredService<MindeeApi>();

await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictPostAsync<ReceiptV4>(
var ex = await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictPostAsync<ReceiptV4>(
UnitTestBase.GetFakePredictParameter())
);
Assert.IsType<MindeeHttp400ExceptionV1>(ex);
}

[Fact]
Expand All @@ -74,9 +76,10 @@ public async Task Predict_500Error()
File.ReadAllText(Constants.V1RootDir + "errors/error_500_inference_fail.json")
);
var mindeeApi = serviceProvider.GetRequiredService<MindeeApi>();
await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictPostAsync<ReceiptV4>(
var ex = await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictPostAsync<ReceiptV4>(
UnitTestBase.GetFakePredictParameter())
);
Assert.IsType<MindeeHttp500ExceptionV1>(ex);
}

[Fact]
Expand All @@ -88,9 +91,10 @@ public async Task Predict_429Error()
);
var mindeeApi = serviceProvider.GetRequiredService<MindeeApi>();

await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictPostAsync<ReceiptV4>(
var ex = await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictPostAsync<ReceiptV4>(
UnitTestBase.GetFakePredictParameter())
);
Assert.IsType<MindeeHttp429ExceptionV1>(ex);
}

[Fact]
Expand All @@ -102,9 +106,10 @@ public async Task Predict_401Error()
);
var mindeeApi = serviceProvider.GetRequiredService<MindeeApi>();

await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictPostAsync<ReceiptV4>(
var ex = await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictPostAsync<ReceiptV4>(
UnitTestBase.GetFakePredictParameter())
);
Assert.IsType<MindeeHttp401ExceptionV1>(ex);
}

[Fact]
Expand All @@ -116,9 +121,10 @@ public async Task PredictAsyncPostAsync_WithFailForbidden()
);
var mindeeApi = serviceProvider.GetRequiredService<MindeeApi>();

await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictAsyncPostAsync<ReceiptV4>(
var ex = await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() => mindeeApi.PredictAsyncPostAsync<ReceiptV4>(
UnitTestBase.GetFakePredictParameter())
);
Assert.IsType<MindeeHttp403ExceptionV1>(ex);
}

[Fact]
Expand Down Expand Up @@ -169,7 +175,7 @@ public async Task DocumentQueueGetAsync_WithJobFailed()
);
var mindeeApi = serviceProvider.GetRequiredService<MindeeApi>();

await Assert.ThrowsAsync<MindeeHttpExceptionV1>(() =>
var ex = await Assert.ThrowsAnyAsync<MindeeHttpExceptionV1>(() =>
mindeeApi.DocumentQueueGetAsync<InvoiceV4>("d88406ed-47bd-4db0-b3f3-145c8667a343")
);
}
Expand Down
Loading