Error Contract
Every error response from vibi-bff has a single wire shape:
// HTTP 4xx / 5xx
{
"error": "<machine_readable_code_or_message>",
"detail": "<optional_extra_context>"
}
Code: vibi-bff/src/main/kotlin/com/vibi/bff/plugins/ErrorHandling.kt. The mobile client runs Ktor with expectSuccess = true, so non-2xx responses automatically throw ResponseException.
Mapping table
| Exception type | HTTP | error field | Trigger |
|---|---|---|---|
NotFoundException | 404 | cause.message | Explicitly thrown by a route handler (e.g. jobId not found) |
IllegalArgumentException | 400 | cause.message | DTO validation via require(...) failure — most validation today flows through this path (free-form messages) |
BadRequestException | 400 | bad_request | Malformed / unparseable JSON request body (previously leaked as 500) |
ApiErrorException | (specified) | errorCode + detail | Structured validation failure with a stable machine code — see the catalog below |
PersoApiException (401) | 401 | Authentication failed with Perso | Upstream 401 |
PersoApiException (402) | 402 | Insufficient Perso quota | Upstream 402 (workspace limit) |
PersoApiException (429) | 429 | Perso rate limit exceeded, please try again later | Upstream 429 |
PersoApiException (4xx) | 400 | Invalid request to Perso | Upstream 4xx |
PersoApiException (5xx) | 502 | Perso service unavailable | Upstream 5xx |
Other Throwable | 500 | Internal server error | Unhandled exception |
| Client disconnect | (no response) | — | ChannelWriteException, Broken pipe, etc. are logged at DEBUG only |
The sanitize convention (
34b7002 fix(security)) keeps raw upstream messages out of thedetailfield — Perso wording is mapped only by status code, and IAP verifier reasons collapse to a singlereceipt_invalidregardless of the underlying cause (refund vs wrong productId vs replay). Specifics live in BFF logs.
429from rate limiting. The KtorRateLimitplugin guards auth login (10/min per IP), render submit (10/min) and separation submit (20/min, per user with IP fallback). A throttled request returns429with aRetry-Afterheader and may not carry the standard{error, detail}body — branch on the status, not the body, for429. (The credits-specific429 admin_grant_daily_cap_exceededabove does use the structured body.)
Structured error codes (ApiErrorException)
Cases where the error field is a machine code rather than a human-readable sentence — clients can branch on the error value.
Credits / IAP — /api/v2/credits/*
error | HTTP | Origin | Notes |
|---|---|---|---|
missing_duration_ms | 400 | GET /credits/cost | durationMs query param missing or non-numeric |
duration_ms_negative | 400 | GET /credits/cost | durationMs < 0 |
insufficient_credits | 402 | POST /separate | Balance below the separation cost quote |
invalid_platform | 400 | POST /credits/purchase | platform not in {"apple","google"} |
missing_receipt | 400 | POST /credits/purchase | transactionId or receipt blank |
unknown_product | 400 | POST /credits/{purchase,admin-grant} | productId not in CreditCatalog |
iap_unconfigured | 400 | POST /credits/purchase | Platform-side IAP verifier env (IAP_APPLE_* / IAP_GOOGLE_*) is blank |
receipt_invalid | 400 | POST /credits/purchase | Upstream verification failed (sanitized — internal reason in BFF logs only) |
receipt_verify_unavailable | 502 | POST /credits/purchase | Apple/Google upstream transient error. Safe to retry with the same transactionId. |
admin_grant_daily_cap_exceeded | 429 | POST /credits/admin-grant | Admin top-ups exceeded ADMIN_GRANT_DAILY_CAP (default 1000) in a rolling 24h |
Auth — submit endpoints
error | HTTP | Origin | Notes |
|---|---|---|---|
account_deleted | 401 | POST /render, /render/v3, /render/inputs, POST /separate | A structurally-valid JWT whose user row no longer exists — the submit path re-checks account existence so a deleted account can't keep spending. Re-auth required. |
admin_required | 403 | /api/v2/admin/*, POST /credits/admin-grant | JWT lacks the admin role |
Render — /api/v2/render*
error | HTTP | Origin | Notes |
|---|---|---|---|
invalid_stem_url | 400 | POST /render | A separationDirectives[*].selections[].audioUrl is not a BFF-signed /separate/.../stem/ URL |
r2_disabled | 503 | POST /assets/upload-url, POST /render/v3 (when an asset requires R2) | R2_BUCKET not configured; v3 path is unavailable |
Separation — /api/v2/separate*
error | HTTP | Origin | Notes |
|---|---|---|---|
unsupported_audio_format | 400 | POST /separate | File extension / codec not in {m4a, mp3, wav} whitelist (e.g. flac, video file, ogg) |
insufficient_credits | 402 | POST /separate | (Same code as above — credits are charged inside the route) |
Earlier doc revisions listed a
trim_*family (partial_trim_range,trim_start_negative,trim_range_invalid,trim_range_too_short,trim_end_exceeds_duration) and anffmpeg_errorcode. None of these are thrown today — the/separatecontract was simplified to audio-only and the mobile client does trim + audio extract itself, so the BFF no longer validates trim windows. If you need to branch on validation failures from this route, use the HTTP status (400 fromIllegalArgumentException) and the free-formerrormessage.
Client handling patterns
Basic try/catch
import io.ktor.client.plugins.ResponseException
try {
val resp = bffApi.startSeparation(file = part, spec = spec)
} catch (e: ResponseException) {
val status = e.response.status // 402, 429, 502, ...
val body = e.response.body<ErrorResponse>()
when (status.value) {
402 -> showQuotaOrCreditDialog(body.error) // insufficient_credits vs Perso 402
429 -> retryWithBackoff()
502 -> showServiceUnavailableSnack()
else -> showGenericError(body.error)
}
}
Machine-code branching (credits + audio format)
catch (e: ResponseException) {
val body = e.response.body<ErrorResponse>()
when (body.error) {
"insufficient_credits" -> openCreditPurchaseSheet()
"unsupported_audio_format" -> showError("Pick an m4a / mp3 / wav file")
"receipt_verify_unavailable" -> retryWithBackoff() // 502, safe retry
"iap_unconfigured" -> hidePurchaseButton() // dev/test build
"account_deleted" -> forceReLogin() // 401, token outlived the account
else -> showGenericError(body.error)
}
}
Token expiry (stem downloads)
When the ?token=… for stem downloads expires, the fetch fails with 401/403.
suspend fun fetchStem(jobId: String, stemId: String): ByteArray = try {
bffApi.downloadStem(currentSignedUrl)
} catch (e: ResponseException) when (e.response.status.value) {
401, 403 -> {
// Call status again to get a fresh token
val fresh = bffApi.getSeparationStatus(jobId)
val url = fresh.stems.first { it.stemId == stemId }.url
bffApi.downloadStem(url)
}
else -> throw e
}
Code references
- Handler:
vibi-bff/src/main/kotlin/com/vibi/bff/plugins/ErrorHandling.kt - Response DTO:
vibi-bff/.../model/BffModels.kt#ErrorResponse - Error codes emitted from routes: grep
ApiErrorException(invibi-bff/src/main/kotlin/com/vibi/bff/routes/ - Client:
vibi-mobile/shared/.../data/remote/api/BffApi.kt(KtorexpectSuccess = true)