Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
25 changes: 23 additions & 2 deletions src/Monnify/Http/MonnifyHttpClientBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -60,7 +60,7 @@ protected async Task<TResponseBody> SendAsync<TResponseBody>(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
Expand Down Expand Up @@ -88,6 +88,27 @@ protected async Task<TResponseBody> SendAsync<TResponseBody>(HttpRequestMessage
return envelope.ResponseBody;
}

/// <summary>
/// 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.
/// </summary>
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<string> ReadContentAsStringAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
#if NET8_0_OR_GREATER
Expand Down
39 changes: 38 additions & 1 deletion tests/Monnify.Tests/Http/MonnifyHttpClientBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public FakeTypedClient(HttpClient httpClient) : base(httpClient) { }

public Task<TResponseBody> Call<TResponseBody>(CancellationToken cancellationToken = default) =>
SendAsync<TResponseBody>(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) =>
Expand Down Expand Up @@ -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<MonnifyDeserializationException>(() => client.Call<FakePayload>());
var ex = await Assert.ThrowsAsync<MonnifyApiException>(() => client.Call<FakePayload>());

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<MonnifyDeserializationException>(() => client.Call<FakePayload>());

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<MonnifyApiException>(() => client.CallVoid());

Assert.Equal(502, ex.HttpStatusCode);
Assert.Equal("error code: 502", ex.RawResponseBody);
}

[Fact]
Expand Down
Loading