diff --git a/CHANGELOG.md b/CHANGELOG.md index 534d7a0..45035ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ own version is independent of Monnify's API versioning. ### Fixed * envelope handling now tolerates endpoints (e.g. paycodes) that omit `requestSuccessful` from their response +* a non-JSON error body (e.g. a proxy/gateway's own error page for a 502/504) on a failing HTTP status now throws `MonnifyApiException` with the real status code, instead of a misleading `MonnifyDeserializationException` — found via a real sandbox 502 while dogfooding the published package before v1 ## [0.5.0](https://github.com/Monnify/monnify-dotnet-lib/compare/v0.4.0...v0.5.0) (2026-06-30) diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 768812c..507389e 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -35,7 +35,7 @@ Status legend: | `IMonnifyCollectionsClient.UpdateLimitProfileAsync` | `PUT /api/v1/limit-profile/{limitProfileCode}` | **Implemented** | Profile code goes in the path; body has only the four editable fields | | `IMonnifyCollectionsClient.CreateReservedAccountWithLimitAsync` | `POST /api/v1/bank-transfer/reserved-accounts/limit` | **Implemented** | Separate from `CreateReservedAccountAsync` (v2 path); requires `limitProfileCode` | | `IMonnifyCollectionsClient.UpdateReservedAccountLimitAsync` | `PUT /api/v1/bank-transfer/reserved-accounts/limit` | **Implemented** | Sandbox-verified | -| `IMonnifyCollectionsClient.CreateSubAccountsAsync` | `POST /api/v1/sub-accounts` | **Implemented** | Body and response are both arrays; requires relationship-manager approval for live | +| `IMonnifyCollectionsClient.CreateSubAccountsAsync` | `POST /api/v1/sub-accounts` | **Implemented** | Body and response are both arrays; requires relationship-manager approval for live. Sandbox-confirmed via raw `curl` (identical payload, same failure) that a fintech/mobile-money bank code as the destination (e.g. `305` PAYCOM/OPay) gets rejected with a generic `HTTP 500` / `responseCode: "99"` rather than a clean validation error - a traditional bank code (e.g. `044` Access Bank) succeeds with the exact same request shape. This is Monnify's own sandbox behavior, not an SDK bug | | `IMonnifyCollectionsClient.GetSubAccountsAsync` | `GET /api/v1/sub-accounts` | **Implemented** | Returns flat array (not paged) | | `IMonnifyCollectionsClient.UpdateSubAccountAsync` | `PUT /api/v1/sub-accounts` | **Implemented** | `subAccountCode` included in the PUT body, not the path | | `IMonnifyCollectionsClient.DeleteSubAccountAsync` | `DELETE /api/v1/sub-accounts/{subAccountCode}` | **Implemented** | Returns no `responseBody` — handled via `SendVoidAsync` | diff --git a/src/Monnify/Http/MonnifyHttpClientBase.cs b/src/Monnify/Http/MonnifyHttpClientBase.cs index fe5e72c..d985bb8 100644 --- a/src/Monnify/Http/MonnifyHttpClientBase.cs +++ b/src/Monnify/Http/MonnifyHttpClientBase.cs @@ -32,7 +32,7 @@ protected async Task SendVoidAsync(HttpRequestMessage request, CancellationToken } catch (JsonException ex) { - throw new MonnifyDeserializationException("Failed to parse the response received from Monnify.", json, ex); + throw CreateExceptionForUnparsableBody(response, json, ex); } if (envelope is null || envelope.RequestSuccessful == false) @@ -60,7 +60,7 @@ protected async Task SendAsync(HttpRequestMessage } catch (JsonException ex) { - throw new MonnifyDeserializationException("Failed to parse the response received from Monnify.", json, ex); + throw CreateExceptionForUnparsableBody(response, json, ex); } // Treat as a definitive failure if RequestSuccessful is explicitly false, or if it is @@ -88,6 +88,27 @@ protected async Task SendAsync(HttpRequestMessage return envelope.ResponseBody; } + /// + /// A body that fails to parse as JSON at all (not just as an envelope) is unambiguously a + /// failure whenever the HTTP status itself already says so - e.g. a proxy/gateway's own error + /// page (plain text or HTML, not even JSON) for a 502/504. Surface that the same way as any + /// other API failure rather than a deserialization error, which is reserved for the genuinely + /// unexpected case: Monnify reporting HTTP success while returning a body that doesn't parse. + /// + private static Exception CreateExceptionForUnparsableBody(HttpResponseMessage response, string json, JsonException innerException) + { + if (!response.IsSuccessStatusCode) + { + return new MonnifyApiException( + "UNKNOWN", + $"Monnify returned an unsuccessful response (HTTP {(int)response.StatusCode}).", + (int)response.StatusCode, + json); + } + + return new MonnifyDeserializationException("Failed to parse the response received from Monnify.", json, innerException); + } + private static Task ReadContentAsStringAsync(HttpResponseMessage response, CancellationToken cancellationToken) { #if NET8_0_OR_GREATER diff --git a/tests/Monnify.Tests/Http/MonnifyHttpClientBaseTests.cs b/tests/Monnify.Tests/Http/MonnifyHttpClientBaseTests.cs index 447dd10..6624784 100644 --- a/tests/Monnify.Tests/Http/MonnifyHttpClientBaseTests.cs +++ b/tests/Monnify.Tests/Http/MonnifyHttpClientBaseTests.cs @@ -21,6 +21,9 @@ public FakeTypedClient(HttpClient httpClient) : base(httpClient) { } public Task Call(CancellationToken cancellationToken = default) => SendAsync(new HttpRequestMessage(HttpMethod.Get, "/anything"), cancellationToken); + + public Task CallVoid(CancellationToken cancellationToken = default) => + SendVoidAsync(new HttpRequestMessage(HttpMethod.Delete, "/anything"), cancellationToken); } private static FakeTypedClient CreateClient(FakeHttpMessageHandler handler) => @@ -59,13 +62,47 @@ public async Task SendAsync_RequestSuccessfulFalse_ThrowsMonnifyApiException_Wit [Fact] public async Task SendAsync_NonSuccessHttpStatus_ThrowsMonnifyApiException_PreservingRawBody() { + // Not even JSON, e.g. a proxy/CDN's own error page for a 502/504 - "not an envelope at + // all" previously fell into MonnifyDeserializationException here, which was misleading: + // the HTTP status already tells us unambiguously that the call failed. var handler = new FakeHttpMessageHandler(); handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.InternalServerError, "not an envelope at all")); var client = CreateClient(handler); - var ex = await Assert.ThrowsAsync(() => client.Call()); + var ex = await Assert.ThrowsAsync(() => client.Call()); + Assert.Equal(500, ex.HttpStatusCode); Assert.Equal("not an envelope at all", ex.RawResponseBody); + Assert.Contains("HTTP 500", ex.ResponseMessage); + } + + [Fact] + public async Task SendAsync_SuccessHttpStatus_UnparsableBody_ThrowsMonnifyDeserializationException() + { + // The inverse of the case above: HTTP said success, but the body still isn't valid JSON. + // That's a genuine contract violation worth its own distinct exception, not something to + // paper over with the generic API-failure path. + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, "not json at all")); + var client = CreateClient(handler); + + var ex = await Assert.ThrowsAsync(() => client.Call()); + + Assert.Equal("not json at all", ex.RawResponseBody); + } + + [Fact] + public async Task SendVoidAsync_NonSuccessHttpStatus_UnparsableBody_ThrowsMonnifyApiException() + { + // Same fix as SendAsync above, applied to the void path used by e.g. DeleteSubAccountAsync. + var handler = new FakeHttpMessageHandler(); + handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.BadGateway, "error code: 502")); + var client = CreateClient(handler); + + var ex = await Assert.ThrowsAsync(() => client.CallVoid()); + + Assert.Equal(502, ex.HttpStatusCode); + Assert.Equal("error code: 502", ex.RawResponseBody); } [Fact]