All docs
ReferenceSpecs

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 typeHTTPerror fieldTrigger
NotFoundException404cause.messageExplicitly thrown by a route handler (e.g. jobId not found)
IllegalArgumentException400cause.messageDTO validation via require(...) failure — most validation today flows through this path (free-form messages)
BadRequestException400bad_requestMalformed / unparseable JSON request body (previously leaked as 500)
ApiErrorException(specified)errorCode + detailStructured validation failure with a stable machine code — see the catalog below
PersoApiException (401)401Authentication failed with PersoUpstream 401
PersoApiException (402)402Insufficient Perso quotaUpstream 402 (workspace limit)
PersoApiException (429)429Perso rate limit exceeded, please try again laterUpstream 429
PersoApiException (4xx)400Invalid request to PersoUpstream 4xx
PersoApiException (5xx)502Perso service unavailableUpstream 5xx
Other Throwable500Internal server errorUnhandled 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 the detail field — Perso wording is mapped only by status code, and IAP verifier reasons collapse to a single receipt_invalid regardless of the underlying cause (refund vs wrong productId vs replay). Specifics live in BFF logs.

429 from rate limiting. The Ktor RateLimit plugin guards auth login (10/min per IP), render submit (10/min) and separation submit (20/min, per user with IP fallback). A throttled request returns 429 with a Retry-After header and may not carry the standard {error, detail} body — branch on the status, not the body, for 429. (The credits-specific 429 admin_grant_daily_cap_exceeded above 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/*

errorHTTPOriginNotes
missing_duration_ms400GET /credits/costdurationMs query param missing or non-numeric
duration_ms_negative400GET /credits/costdurationMs < 0
insufficient_credits402POST /separateBalance below the separation cost quote
invalid_platform400POST /credits/purchaseplatform not in {"apple","google"}
missing_receipt400POST /credits/purchasetransactionId or receipt blank
unknown_product400POST /credits/{purchase,admin-grant}productId not in CreditCatalog
iap_unconfigured400POST /credits/purchasePlatform-side IAP verifier env (IAP_APPLE_* / IAP_GOOGLE_*) is blank
receipt_invalid400POST /credits/purchaseUpstream verification failed (sanitized — internal reason in BFF logs only)
receipt_verify_unavailable502POST /credits/purchaseApple/Google upstream transient error. Safe to retry with the same transactionId.
admin_grant_daily_cap_exceeded429POST /credits/admin-grantAdmin top-ups exceeded ADMIN_GRANT_DAILY_CAP (default 1000) in a rolling 24h

Auth — submit endpoints

errorHTTPOriginNotes
account_deleted401POST /render, /render/v3, /render/inputs, POST /separateA 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_required403/api/v2/admin/*, POST /credits/admin-grantJWT lacks the admin role

Render — /api/v2/render*

errorHTTPOriginNotes
invalid_stem_url400POST /renderA separationDirectives[*].selections[].audioUrl is not a BFF-signed /separate/.../stem/ URL
r2_disabled503POST /assets/upload-url, POST /render/v3 (when an asset requires R2)R2_BUCKET not configured; v3 path is unavailable

Separation — /api/v2/separate*

errorHTTPOriginNotes
unsupported_audio_format400POST /separateFile extension / codec not in {m4a, mp3, wav} whitelist (e.g. flac, video file, ogg)
insufficient_credits402POST /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 an ffmpeg_error code. None of these are thrown today — the /separate contract 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 from IllegalArgumentException) and the free-form error message.


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( in vibi-bff/src/main/kotlin/com/vibi/bff/routes/
  • Client: vibi-mobile/shared/.../data/remote/api/BffApi.kt (Ktor expectSuccess = true)