feat: add proper error handling with full API error details

- Add ApiResult<T> type to carry success/failure with error details
- Add UpdateDocumentWithResultAsync and CreateTagWithResultAsync methods
- Update DocumentTools.Update and TagTools.Create to return actual
  HTTP status codes and response bodies in error responses
- Add comprehensive tests for error handling (18 new tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Barry Walker
2026-01-13 18:59:16 -05:00
parent 39768fdd45
commit c67781bac5
9 changed files with 462 additions and 33 deletions
+40
View File
@@ -0,0 +1,40 @@
using System.Net;
namespace PaperlessMCP.Client;
/// <summary>
/// Represents the result of an API operation that can either succeed or fail with details.
/// </summary>
/// <typeparam name="T">The type of the success value.</typeparam>
public readonly record struct ApiResult<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public ApiError? Error { get; }
private ApiResult(bool isSuccess, T? value, ApiError? error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public static ApiResult<T> Success(T value) => new(true, value, null);
public static ApiResult<T> Failure(HttpStatusCode statusCode, string message, string? responseBody = null) =>
new(false, default, new ApiError(statusCode, message, responseBody));
public static ApiResult<T> Failure(ApiError error) => new(false, default, error);
public static implicit operator bool(ApiResult<T> result) => result.IsSuccess;
}
/// <summary>
/// Details about an API error.
/// </summary>
public record ApiError(HttpStatusCode StatusCode, string Message, string? ResponseBody)
{
public override string ToString() =>
$"HTTP {(int)StatusCode} {StatusCode}: {Message}" +
(ResponseBody != null ? $" - {ResponseBody}" : "");
}
+84 -22
View File
@@ -180,7 +180,16 @@ public class PaperlessClient
/// </summary>
public async Task<Document?> UpdateDocumentAsync(int id, DocumentUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchAsync<Document>($"api/documents/{id}/", request, cancellationToken).ConfigureAwait(false);
var result = await UpdateDocumentWithResultAsync(id, request, cancellationToken).ConfigureAwait(false);
return result.IsSuccess ? result.Value : null;
}
/// <summary>
/// Updates a document with full error details.
/// </summary>
public async Task<ApiResult<Document>> UpdateDocumentWithResultAsync(int id, DocumentUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchWithResultAsync<Document>($"api/documents/{id}/", request, cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -188,7 +197,16 @@ public class PaperlessClient
/// </summary>
public async Task<bool> DeleteDocumentAsync(int id, CancellationToken cancellationToken = default)
{
return await DeleteAsync($"api/documents/{id}/", cancellationToken).ConfigureAwait(false);
var result = await DeleteDocumentWithResultAsync(id, cancellationToken).ConfigureAwait(false);
return result.IsSuccess;
}
/// <summary>
/// Deletes a document with full error details.
/// </summary>
public async Task<ApiResult<bool>> DeleteDocumentWithResultAsync(int id, CancellationToken cancellationToken = default)
{
return await DeleteWithResultAsync($"api/documents/{id}/", cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -428,17 +446,35 @@ public class PaperlessClient
public async Task<Tag?> CreateTagAsync(TagCreateRequest request, CancellationToken cancellationToken = default)
{
return await PostAsync<Tag>("api/tags/", request, cancellationToken).ConfigureAwait(false);
var result = await CreateTagWithResultAsync(request, cancellationToken).ConfigureAwait(false);
return result.IsSuccess ? result.Value : null;
}
public async Task<ApiResult<Tag>> CreateTagWithResultAsync(TagCreateRequest request, CancellationToken cancellationToken = default)
{
return await PostWithResultAsync<Tag>("api/tags/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<Tag?> UpdateTagAsync(int id, TagUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchAsync<Tag>($"api/tags/{id}/", request, cancellationToken).ConfigureAwait(false);
var result = await UpdateTagWithResultAsync(id, request, cancellationToken).ConfigureAwait(false);
return result.IsSuccess ? result.Value : null;
}
public async Task<ApiResult<Tag>> UpdateTagWithResultAsync(int id, TagUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchWithResultAsync<Tag>($"api/tags/{id}/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> DeleteTagAsync(int id, CancellationToken cancellationToken = default)
{
return await DeleteAsync($"api/tags/{id}/", cancellationToken).ConfigureAwait(false);
var result = await DeleteTagWithResultAsync(id, cancellationToken).ConfigureAwait(false);
return result.IsSuccess;
}
public async Task<ApiResult<bool>> DeleteTagWithResultAsync(int id, CancellationToken cancellationToken = default)
{
return await DeleteWithResultAsync($"api/tags/{id}/", cancellationToken).ConfigureAwait(false);
}
#endregion
@@ -632,7 +668,7 @@ public class PaperlessClient
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
await LogErrorResponse(response, "GET", url).ConfigureAwait(false);
await CreateApiError(response, "GET", url).ConfigureAwait(false);
return default;
}
catch (Exception ex)
@@ -642,7 +678,7 @@ public class PaperlessClient
}
}
private async Task<T?> PostAsync<T>(string url, object request, CancellationToken cancellationToken)
private async Task<ApiResult<T>> PostWithResultAsync<T>(string url, object request, CancellationToken cancellationToken)
{
try
{
@@ -650,20 +686,23 @@ public class PaperlessClient
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result != null
? ApiResult<T>.Success(result)
: ApiResult<T>.Failure(response.StatusCode, "Empty response body");
}
await LogErrorResponse(response, "POST", url).ConfigureAwait(false);
return default;
var error = await CreateApiError(response, "POST", url).ConfigureAwait(false);
return ApiResult<T>.Failure(error);
}
catch (Exception ex)
{
_logger.LogError(ex, "POST request failed: {Url}", url);
return default;
return ApiResult<T>.Failure(HttpStatusCode.InternalServerError, ex.Message);
}
}
private async Task<T?> PatchAsync<T>(string url, object request, CancellationToken cancellationToken)
private async Task<ApiResult<T>> PatchWithResultAsync<T>(string url, object request, CancellationToken cancellationToken)
{
try
{
@@ -672,20 +711,23 @@ public class PaperlessClient
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result != null
? ApiResult<T>.Success(result)
: ApiResult<T>.Failure(response.StatusCode, "Empty response body");
}
await LogErrorResponse(response, "PATCH", url).ConfigureAwait(false);
return default;
var error = await CreateApiError(response, "PATCH", url).ConfigureAwait(false);
return ApiResult<T>.Failure(error);
}
catch (Exception ex)
{
_logger.LogError(ex, "PATCH request failed: {Url}", url);
return default;
return ApiResult<T>.Failure(HttpStatusCode.InternalServerError, ex.Message);
}
}
private async Task<bool> DeleteAsync(string url, CancellationToken cancellationToken)
private async Task<ApiResult<bool>> DeleteWithResultAsync(string url, CancellationToken cancellationToken)
{
try
{
@@ -693,24 +735,44 @@ public class PaperlessClient
if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NoContent)
{
return true;
return ApiResult<bool>.Success(true);
}
await LogErrorResponse(response, "DELETE", url).ConfigureAwait(false);
return false;
var error = await CreateApiError(response, "DELETE", url).ConfigureAwait(false);
return ApiResult<bool>.Failure(error);
}
catch (Exception ex)
{
_logger.LogError(ex, "DELETE request failed: {Url}", url);
return false;
return ApiResult<bool>.Failure(HttpStatusCode.InternalServerError, ex.Message);
}
}
private async Task LogErrorResponse(HttpResponseMessage response, string method, string url)
private async Task<ApiError> CreateApiError(HttpResponseMessage response, string method, string url)
{
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
_logger.LogError("{Method} {Url} failed with {StatusCode}: {Body}",
method, url, (int)response.StatusCode, body);
return new ApiError(response.StatusCode, response.ReasonPhrase ?? "Unknown error", body);
}
// Legacy methods for backward compatibility - will be removed after migration
private async Task<T?> PostAsync<T>(string url, object request, CancellationToken cancellationToken)
{
var result = await PostWithResultAsync<T>(url, request, cancellationToken).ConfigureAwait(false);
return result.IsSuccess ? result.Value : default;
}
private async Task<T?> PatchAsync<T>(string url, object request, CancellationToken cancellationToken)
{
var result = await PatchWithResultAsync<T>(url, request, cancellationToken).ConfigureAwait(false);
return result.IsSuccess ? result.Value : default;
}
private async Task<bool> DeleteAsync(string url, CancellationToken cancellationToken)
{
var result = await DeleteWithResultAsync(url, cancellationToken).ConfigureAwait(false);
return result.IsSuccess;
}
#endregion
+8 -6
View File
@@ -350,20 +350,22 @@ public static class DocumentTools
Created = ParseDate(created)
};
var document = await client.UpdateDocumentAsync(id, request).ConfigureAwait(false);
var result = await client.UpdateDocumentWithResultAsync(id, request).ConfigureAwait(false);
if (document == null)
if (!result.IsSuccess)
{
var error = result.Error!;
var errorResponse = McpErrorResponse.Create(
ErrorCodes.NotFound,
$"Document with ID {id} not found or update failed",
meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl }
error.StatusCode == System.Net.HttpStatusCode.NotFound ? ErrorCodes.NotFound : ErrorCodes.UpstreamError,
$"Failed to update document {id}: {error.Message}",
new { status_code = (int)error.StatusCode, response_body = error.ResponseBody },
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
);
return JsonSerializer.Serialize(errorResponse);
}
var response = McpResponse<Document>.Success(
document,
result.Value!,
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
);
return JsonSerializer.Serialize(response);
+7 -5
View File
@@ -82,20 +82,22 @@ public static class TagTools
IsInboxTag = isInboxTag
};
var tag = await client.CreateTagAsync(request).ConfigureAwait(false);
var result = await client.CreateTagWithResultAsync(request).ConfigureAwait(false);
if (tag == null)
if (!result.IsSuccess)
{
var error = result.Error!;
var errorResponse = McpErrorResponse.Create(
ErrorCodes.UpstreamError,
"Failed to create tag",
meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl }
$"Failed to create tag: {error.Message}",
new { status_code = (int)error.StatusCode, response_body = error.ResponseBody },
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
);
return JsonSerializer.Serialize(errorResponse);
}
var response = McpResponse<Tag>.Success(
tag,
result.Value!,
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
);
return JsonSerializer.Serialize(response);