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, require(...) failure
ApiErrorException(specified)errorCode + detailStructured validation failure (e.g. trim_end_exceeds_duration)
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

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.

errorHTTPOrigindetail
partial_trim_range400POST /api/v2/separate
trim_start_negative400POST /api/v2/separate
trim_range_invalid400POST /api/v2/separate
trim_range_too_short400POST /api/v2/separate
trim_end_exceeds_duration400POST /api/v2/separatetrimEndMs=… duration=…
ffmpeg_error500POST /api/v2/separate trim stageTail of ffmpeg stderr

Why 500 ffmpeg_error and not 501 Not Implemented: the ffmpeg call is implemented and did execute, but failed. 501 semantically means "Not Implemented", which would be inaccurate.


Client handling patterns

Basic try/catch

import io.ktor.client.plugins.ResponseException

try {
    val res = bffApi.submitAutoDubJob(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 -> showQuotaExhaustedDialog()
        429 -> retryWithBackoff()
        502 -> showServiceUnavailableSnack()
        else -> showGenericError(body.error)
    }
}

Machine-code branching (separation trim)

catch (e: ResponseException) {
    val body = e.response.body<ErrorResponse>()
    when (body.error) {
        "trim_end_exceeds_duration" -> {
            // detail format: "trimEndMs=12345 duration=10000"
            val actualDuration = parseDuration(body.detail)
            promptUserToShortenRange(actualDuration)
        }
        "trim_range_too_short" -> showError("Select at least 500ms")
        else -> showGenericError(body.error)
    }
}

Token expiry (separation / auto-dub / subtitle downloads)

When the ?token=… for stem / mix / subtitle SRT / dubbing results expires, the download 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
  • Client: vibi-mobile/shared/.../data/remote/api/BffApi.kt (Ktor expectSuccess = true)