Skip to content

Handle errors

How SDK calls fail and what to do about it.

Results, not exceptions

Management calls — videos, collections, live streams, player settings — return a result you check rather than an exception you catch. That result is BunnyResult<T>: either Ok with the value, or Err with a typed error.

when (val result = repo.getLiveStream(libraryId, streamId)) {
    is BunnyResult.Ok -> render(result.value)
    is BunnyResult.Err -> if (result.isTerminal) giveUp(result.message) else retryLater()
}

fold is shorter when both sides are one-liners. Note that success comes first:

repo.getLiveStream(libraryId, streamId).fold(
    onOk = { stream -> render(stream) },
    onErr = { error -> log(error.message) },
)

Also available: getOrNull(), errorOrNull() and map { }.

Terminal vs transient

This split is the one decision that matters in error handling here, and every error answers it directly through isTerminal:

  • Terminal401, 403, 404, 410, plus the device-side cases below. The request will keep failing until something changes on your side or in the dashboard: wrong or revoked API key, a deleted stream or video, an ended stream. Do not retry in a loop; surface the state to the user.
  • Transient5xx and transport errors (status 0). Retry with a delay, or just keep polling if you were polling.
if (error.isTerminal) showPermanentFailure(error.message) else scheduleRetry()

The error taxonomy

BunnyError has seven cases. Branch on the type when you want to react specifically; branch on isTerminal when you only need to know whether retrying is worth it.

Case When httpStatus isTerminal
Auth 401/403 — key missing, expired, or not allowed for this library 401/403 true
NotFound 404 — wrong video, stream or library id 404 true
Http any other non-success status: 5xx, rate limiting, validation as returned true only for 410
Network no usable response: DNS, connect, socket timeout, TLS, dropped connection 0 false
Decode the response arrived but did not match the expected shape 0 false
LocalFile the device could not read the file picked for upload 0 true
InvalidState the resource forbids the operation: an ended live stream, a stream key not issued yet 0 varies — it says so itself
when (error) {
    is BunnyError.Auth -> promptForNewAccessKey()
    is BunnyError.NotFound -> removeFromList()
    else -> if (error.isTerminal) giveUp(error.message) else scheduleRetry()
}

error.message is a human-readable description, safe to log.

Polling

pollLiveStream returns the same envelope as everything else, so a polling loop reads isTerminal to decide whether to stop:

when (val result = repo.pollLiveStream(libraryId, streamId)) {
    is BunnyResult.Ok -> render(result.value)
    is BunnyResult.Err -> if (result.isTerminal) stopPolling() else Unit  // transient: keep going
}

Both players implement these rules internally; you only need them for your own management calls.

Upload errors

Uploads report failures as an UploadEvent.Failed value on the event stream, not as a thrown exception — so a collect without a catch cannot miss one. The event carries the same BunnyError as everything else:

uploader.observeUpload(uploadId)?.collect { event ->
    if (event is UploadEvent.Failed) {
        val videoId = event.videoId
        if (!event.error.isTerminal && videoId != null) {
            // Resumable path: continue from the offset the server already has
            observe(tusVideoUploader.continueUpload(libraryId, videoId, uri))
        } else {
            showError(event.error.message)
        }
    }
}

LocalFile means the picked file could not be read — a different file is the only fix. InvalidState from continueUpload means that uploader cannot resume; only the TUS one records an offset.

See Upload videos for the full flow.

Player errors

The players show their own error states and recover from transient stream problems on their own. To also handle playback errors in your code, set onPlaybackError on the player view:

player.onPlaybackError = { message ->
    // log it, report it, show your own UI
}

Do not assign your own PlayerStateListener to the playback engine. There is one listener slot and the player view owns it, so taking it over stops the built-in UI updating - the play/pause button, the error banner, chapters and the seek bar all go stale. Everything the listener reports is available as a callback on the view instead: onPlaybackError, onPlayingChanged, onMutedChanged, onLoadingChanged, onPlaybackSpeedChanged, onChaptersUpdated, onMomentsUpdated, onRetentionGraphUpdated, onPlayerTypeChanged and onVideoSizeChanged.

One envelope, everywhere

There is no second error style left to learn. In 3.x the generated REST clients (videosApi and collectionsApi) sat on the public API and threwClientException for 4xx, ServerException for 5xx — so every call site around them needed its own try/catch.

Those clients are gone from the public surface. Videos and collections now go through videoRepository and collectionRepository, which answer with BunnyResult like everything else, so the code on this page covers the whole SDK.