Skip to content

Commit f3d3625

Browse files
committed
feat(onramp): typed error hierarchy for Coinbase API errors
Replace CoinbaseOnRampApiError data class with a sealed class hierarchy so each error type (GuestRegionForbidden, GuestTransactionLimit, etc.) gets its own class name for distinct grouping in Bugsnag. Uses a private DTO for JSON deserialization with all-nullable fields, fixing parsing of real Coinbase responses that lack the old required code/message fields. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 3abfbbf commit f3d3625

2 files changed

Lines changed: 130 additions & 13 deletions

File tree

apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,17 @@ class CoinbaseOnRampController @Inject constructor(
277277
}.onFailure { error ->
278278
if (error is HttpException) {
279279
val errorBody = error.response()?.errorBody()?.string()
280+
val coinbaseError = errorBody?.let { CoinbaseOnRampApiError.parse(it) }
280281
trace(
281282
message = "Coinbase order lookup HTTP ${error.code()}",
282283
tag = "OnRamp",
283284
metadata = {
284285
"orderId" to orderId
285-
"responseBody" to errorBody.orEmpty()
286+
"httpCode" to error.code().toString()
287+
"errorType" to (coinbaseError?.let { it::class.simpleName } ?: "unknown")
288+
"correlationId" to coinbaseError?.correlationId
289+
"responseBody" to errorBody
290+
"errorLink" to coinbaseError?.errorLink
286291
},
287292
error = error,
288293
)
@@ -381,19 +386,21 @@ class CoinbaseOnRampController @Inject constructor(
381386
onFailure = { error ->
382387
if (error is HttpException) {
383388
val errorBody = error.response()?.errorBody()?.string()
389+
val coinbaseError = errorBody?.let { CoinbaseOnRampApiError.parse(it) }
384390
trace(
385391
message = "Coinbase OnRamp HTTP ${error.code()}",
386392
tag = "OnRamp",
387-
metadata = { "responseBody" to errorBody.orEmpty() },
393+
metadata = {
394+
"httpCode" to error.code().toString()
395+
"errorType" to (coinbaseError?.let { it::class.simpleName } ?: "unknown")
396+
"correlationId" to coinbaseError?.correlationId.orEmpty()
397+
"responseBody" to errorBody.orEmpty()
398+
},
388399
error = error,
389400
)
390-
if (errorBody != null) {
391-
val coinbaseError = runCatching { json.decodeFromString<CoinbaseOnRampApiError>(errorBody) }
392-
.getOrNull()
393-
if (coinbaseError != null) {
394-
ErrorUtils.handleError(coinbaseError)
395-
return Result.failure(Throwable(coinbaseError.message))
396-
}
401+
if (coinbaseError != null) {
402+
ErrorUtils.handleError(coinbaseError)
403+
return Result.failure(coinbaseError)
397404
}
398405
}
399406

@@ -422,11 +429,63 @@ sealed class OnRampAuthError(
422429
OnRampAuthError("Phone verification required from Coinbase")
423430
}
424431

432+
sealed class CoinbaseOnRampApiError(
433+
val correlationId: String?,
434+
val errorLink: String?,
435+
override val message: String?,
436+
) : Throwable(message = message), NotifiableError {
437+
438+
class InvalidRequest(correlationId: String?, errorLink: String?, message: String?) :
439+
CoinbaseOnRampApiError(correlationId, errorLink, message)
440+
441+
class NetworkNotTradable(correlationId: String?, errorLink: String?, message: String?) :
442+
CoinbaseOnRampApiError(correlationId, errorLink, message)
443+
444+
class GuestPermissionDenied(correlationId: String?, errorLink: String?, message: String?) :
445+
CoinbaseOnRampApiError(correlationId, errorLink, message)
446+
447+
class GuestRegionForbidden(correlationId: String?, errorLink: String?, message: String?) :
448+
CoinbaseOnRampApiError(correlationId, errorLink, message)
449+
450+
class GuestTransactionLimit(correlationId: String?, errorLink: String?, message: String?) :
451+
CoinbaseOnRampApiError(correlationId, errorLink, message)
452+
453+
class GuestTransactionCount(correlationId: String?, errorLink: String?, message: String?) :
454+
CoinbaseOnRampApiError(correlationId, errorLink, message)
455+
456+
class PhoneNumberVerificationExpired(correlationId: String?, errorLink: String?, message: String?) :
457+
CoinbaseOnRampApiError(correlationId, errorLink, message)
458+
459+
class Unknown(val errorType: String?, correlationId: String?, errorLink: String?, message: String?) :
460+
CoinbaseOnRampApiError(correlationId, errorLink, message ?: "Unknown Coinbase error: $errorType")
461+
462+
companion object {
463+
fun parse(body: String): CoinbaseOnRampApiError? {
464+
val dto = runCatching { json.decodeFromString<CoinbaseErrorDto>(body) }.getOrNull()
465+
?: return null
466+
val type = dto.errorType ?: dto.code
467+
val msg = dto.message ?: dto.errorLink
468+
return when (type) {
469+
"invalid_request" -> InvalidRequest(dto.correlationId, dto.errorLink, msg)
470+
"network_not_tradable" -> NetworkNotTradable(dto.correlationId, dto.errorLink, msg)
471+
"guest_permission_denied" -> GuestPermissionDenied(dto.correlationId, dto.errorLink, msg)
472+
"guest_region_forbidden" -> GuestRegionForbidden(dto.correlationId, dto.errorLink, msg)
473+
"guest_transaction_limit" -> GuestTransactionLimit(dto.correlationId, dto.errorLink, msg)
474+
"guest_transaction_count" -> GuestTransactionCount(dto.correlationId, dto.errorLink, msg)
475+
"phone_number_verification_expired" -> PhoneNumberVerificationExpired(dto.correlationId, dto.errorLink, msg)
476+
else -> Unknown(type, dto.correlationId, dto.errorLink, msg)
477+
}
478+
}
479+
}
480+
}
481+
425482
@OptIn(ExperimentalSerializationApi::class)
426483
@JsonIgnoreUnknownKeys
427484
@Serializable
428-
data class CoinbaseOnRampApiError(
485+
private data class CoinbaseErrorDto(
429486
val correlationId: String? = null,
430-
val code: String,
431-
override val message: String,
432-
) : Throwable(message = message), NotifiableError
487+
val errorType: String? = null,
488+
val errorLink: String? = null,
489+
val code: String? = null,
490+
val message: String? = null,
491+
)

apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,61 @@ class CoinbaseOnRampControllerTest {
175175

176176
// endregion
177177
}
178+
179+
class CoinbaseOnRampApiErrorParseTest {
180+
181+
@Test
182+
fun `parse real Coinbase error response with errorType`() {
183+
val body = """{"correlationId":"9f42a272080a4bc3-IAD","errorLink":"https://docs.cdp.coinbase.com/api-reference/v2/errors","errorType":"guest_region_forbidden"}"""
184+
val error = CoinbaseOnRampApiError.parse(body)
185+
assertIs<CoinbaseOnRampApiError.GuestRegionForbidden>(error)
186+
assertEquals("9f42a272080a4bc3-IAD", error.correlationId)
187+
assertEquals("https://docs.cdp.coinbase.com/api-reference/v2/errors", error.errorLink)
188+
}
189+
190+
@Test
191+
fun `parse all known error types`() {
192+
val expected = mapOf(
193+
"invalid_request" to CoinbaseOnRampApiError.InvalidRequest::class,
194+
"network_not_tradable" to CoinbaseOnRampApiError.NetworkNotTradable::class,
195+
"guest_permission_denied" to CoinbaseOnRampApiError.GuestPermissionDenied::class,
196+
"guest_region_forbidden" to CoinbaseOnRampApiError.GuestRegionForbidden::class,
197+
"guest_transaction_limit" to CoinbaseOnRampApiError.GuestTransactionLimit::class,
198+
"guest_transaction_count" to CoinbaseOnRampApiError.GuestTransactionCount::class,
199+
"phone_number_verification_expired" to CoinbaseOnRampApiError.PhoneNumberVerificationExpired::class,
200+
)
201+
for ((errorType, expectedClass) in expected) {
202+
val body = """{"errorType":"$errorType","correlationId":"abc"}"""
203+
val error = CoinbaseOnRampApiError.parse(body)
204+
assertTrue(expectedClass.isInstance(error), "Failed for errorType: $errorType")
205+
}
206+
}
207+
208+
@Test
209+
fun `parse unknown error type returns Unknown`() {
210+
val body = """{"errorType":"some_future_error","correlationId":"abc"}"""
211+
val error = CoinbaseOnRampApiError.parse(body)
212+
assertIs<CoinbaseOnRampApiError.Unknown>(error)
213+
assertEquals("some_future_error", error.errorType)
214+
}
215+
216+
@Test
217+
fun `parse with message field`() {
218+
val body = """{"errorType":"guest_transaction_limit","message":"limit exceeded","correlationId":"abc"}"""
219+
val error = CoinbaseOnRampApiError.parse(body)
220+
assertIs<CoinbaseOnRampApiError.GuestTransactionLimit>(error)
221+
assertEquals("limit exceeded", error.message)
222+
}
223+
224+
@Test
225+
fun `parse invalid JSON returns null`() {
226+
val error = CoinbaseOnRampApiError.parse("not json")
227+
assertEquals(null, error)
228+
}
229+
230+
@Test
231+
fun `parse empty JSON object returns Unknown`() {
232+
val error = CoinbaseOnRampApiError.parse("{}")
233+
assertIs<CoinbaseOnRampApiError.Unknown>(error)
234+
}
235+
}

0 commit comments

Comments
 (0)