Skip to content

Commit 1cf31cf

Browse files
authored
Merge pull request #25 from Monnify/dev
fix: improve error handling for non-JSON responses and update tests a…
2 parents 92079e0 + c9b0c40 commit 1cf31cf

4 files changed

Lines changed: 63 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ own version is independent of Monnify's API versioning.
111111
### Fixed
112112

113113
* envelope handling now tolerates endpoints (e.g. paycodes) that omit `requestSuccessful` from their response
114+
* 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
114115

115116
## [0.5.0](https://github.com/Monnify/monnify-dotnet-lib/compare/v0.4.0...v0.5.0) (2026-06-30)
116117

docs/COMPATIBILITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Status legend:
3535
| `IMonnifyCollectionsClient.UpdateLimitProfileAsync` | `PUT /api/v1/limit-profile/{limitProfileCode}` | **Implemented** | Profile code goes in the path; body has only the four editable fields |
3636
| `IMonnifyCollectionsClient.CreateReservedAccountWithLimitAsync` | `POST /api/v1/bank-transfer/reserved-accounts/limit` | **Implemented** | Separate from `CreateReservedAccountAsync` (v2 path); requires `limitProfileCode` |
3737
| `IMonnifyCollectionsClient.UpdateReservedAccountLimitAsync` | `PUT /api/v1/bank-transfer/reserved-accounts/limit` | **Implemented** | Sandbox-verified |
38-
| `IMonnifyCollectionsClient.CreateSubAccountsAsync` | `POST /api/v1/sub-accounts` | **Implemented** | Body and response are both arrays; requires relationship-manager approval for live |
38+
| `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 |
3939
| `IMonnifyCollectionsClient.GetSubAccountsAsync` | `GET /api/v1/sub-accounts` | **Implemented** | Returns flat array (not paged) |
4040
| `IMonnifyCollectionsClient.UpdateSubAccountAsync` | `PUT /api/v1/sub-accounts` | **Implemented** | `subAccountCode` included in the PUT body, not the path |
4141
| `IMonnifyCollectionsClient.DeleteSubAccountAsync` | `DELETE /api/v1/sub-accounts/{subAccountCode}` | **Implemented** | Returns no `responseBody` — handled via `SendVoidAsync` |

src/Monnify/Http/MonnifyHttpClientBase.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ protected async Task SendVoidAsync(HttpRequestMessage request, CancellationToken
3232
}
3333
catch (JsonException ex)
3434
{
35-
throw new MonnifyDeserializationException("Failed to parse the response received from Monnify.", json, ex);
35+
throw CreateExceptionForUnparsableBody(response, json, ex);
3636
}
3737

3838
if (envelope is null || envelope.RequestSuccessful == false)
@@ -60,7 +60,7 @@ protected async Task<TResponseBody> SendAsync<TResponseBody>(HttpRequestMessage
6060
}
6161
catch (JsonException ex)
6262
{
63-
throw new MonnifyDeserializationException("Failed to parse the response received from Monnify.", json, ex);
63+
throw CreateExceptionForUnparsableBody(response, json, ex);
6464
}
6565

6666
// Treat as a definitive failure if RequestSuccessful is explicitly false, or if it is
@@ -88,6 +88,27 @@ protected async Task<TResponseBody> SendAsync<TResponseBody>(HttpRequestMessage
8888
return envelope.ResponseBody;
8989
}
9090

91+
/// <summary>
92+
/// A body that fails to parse as JSON at all (not just as an envelope) is unambiguously a
93+
/// failure whenever the HTTP status itself already says so - e.g. a proxy/gateway's own error
94+
/// page (plain text or HTML, not even JSON) for a 502/504. Surface that the same way as any
95+
/// other API failure rather than a deserialization error, which is reserved for the genuinely
96+
/// unexpected case: Monnify reporting HTTP success while returning a body that doesn't parse.
97+
/// </summary>
98+
private static Exception CreateExceptionForUnparsableBody(HttpResponseMessage response, string json, JsonException innerException)
99+
{
100+
if (!response.IsSuccessStatusCode)
101+
{
102+
return new MonnifyApiException(
103+
"UNKNOWN",
104+
$"Monnify returned an unsuccessful response (HTTP {(int)response.StatusCode}).",
105+
(int)response.StatusCode,
106+
json);
107+
}
108+
109+
return new MonnifyDeserializationException("Failed to parse the response received from Monnify.", json, innerException);
110+
}
111+
91112
private static Task<string> ReadContentAsStringAsync(HttpResponseMessage response, CancellationToken cancellationToken)
92113
{
93114
#if NET8_0_OR_GREATER

tests/Monnify.Tests/Http/MonnifyHttpClientBaseTests.cs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public FakeTypedClient(HttpClient httpClient) : base(httpClient) { }
2121

2222
public Task<TResponseBody> Call<TResponseBody>(CancellationToken cancellationToken = default) =>
2323
SendAsync<TResponseBody>(new HttpRequestMessage(HttpMethod.Get, "/anything"), cancellationToken);
24+
25+
public Task CallVoid(CancellationToken cancellationToken = default) =>
26+
SendVoidAsync(new HttpRequestMessage(HttpMethod.Delete, "/anything"), cancellationToken);
2427
}
2528

2629
private static FakeTypedClient CreateClient(FakeHttpMessageHandler handler) =>
@@ -59,13 +62,47 @@ public async Task SendAsync_RequestSuccessfulFalse_ThrowsMonnifyApiException_Wit
5962
[Fact]
6063
public async Task SendAsync_NonSuccessHttpStatus_ThrowsMonnifyApiException_PreservingRawBody()
6164
{
65+
// Not even JSON, e.g. a proxy/CDN's own error page for a 502/504 - "not an envelope at
66+
// all" previously fell into MonnifyDeserializationException here, which was misleading:
67+
// the HTTP status already tells us unambiguously that the call failed.
6268
var handler = new FakeHttpMessageHandler();
6369
handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.InternalServerError, "not an envelope at all"));
6470
var client = CreateClient(handler);
6571

66-
var ex = await Assert.ThrowsAsync<MonnifyDeserializationException>(() => client.Call<FakePayload>());
72+
var ex = await Assert.ThrowsAsync<MonnifyApiException>(() => client.Call<FakePayload>());
6773

74+
Assert.Equal(500, ex.HttpStatusCode);
6875
Assert.Equal("not an envelope at all", ex.RawResponseBody);
76+
Assert.Contains("HTTP 500", ex.ResponseMessage);
77+
}
78+
79+
[Fact]
80+
public async Task SendAsync_SuccessHttpStatus_UnparsableBody_ThrowsMonnifyDeserializationException()
81+
{
82+
// The inverse of the case above: HTTP said success, but the body still isn't valid JSON.
83+
// That's a genuine contract violation worth its own distinct exception, not something to
84+
// paper over with the generic API-failure path.
85+
var handler = new FakeHttpMessageHandler();
86+
handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.OK, "not json at all"));
87+
var client = CreateClient(handler);
88+
89+
var ex = await Assert.ThrowsAsync<MonnifyDeserializationException>(() => client.Call<FakePayload>());
90+
91+
Assert.Equal("not json at all", ex.RawResponseBody);
92+
}
93+
94+
[Fact]
95+
public async Task SendVoidAsync_NonSuccessHttpStatus_UnparsableBody_ThrowsMonnifyApiException()
96+
{
97+
// Same fix as SendAsync above, applied to the void path used by e.g. DeleteSubAccountAsync.
98+
var handler = new FakeHttpMessageHandler();
99+
handler.Enqueue(HttpResponseFactory.Json(HttpStatusCode.BadGateway, "error code: 502"));
100+
var client = CreateClient(handler);
101+
102+
var ex = await Assert.ThrowsAsync<MonnifyApiException>(() => client.CallVoid());
103+
104+
Assert.Equal(502, ex.HttpStatusCode);
105+
Assert.Equal("error code: 502", ex.RawResponseBody);
69106
}
70107

71108
[Fact]

0 commit comments

Comments
 (0)