Skip to content

Instantly share code, notes, and snippets.

@alexandrepiveteau
Created November 15, 2020 14:49
Show Gist options
  • Select an option

  • Save alexandrepiveteau/d6a0d04bd2b0475fb62a1d5e4f5587d7 to your computer and use it in GitHub Desktop.

Select an option

Save alexandrepiveteau/d6a0d04bd2b0475fb62a1d5e4f5587d7 to your computer and use it in GitHub Desktop.
A better coroutine-based HTTP requests manager
package ch.heigvd.iict.sym.labo2.betterComm
import ch.heigvd.iict.sym.labo2.betterComm.RequestManager.Backoff
import ch.heigvd.iict.sym.labo2.betterComm.RequestManager.ResponseOrCancelled.Cancelled
import ch.heigvd.iict.sym.labo2.betterComm.RequestManager.ResponseOrCancelled.Success
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* A class that can handle requests in a suspending fashion, with support for custom [Backoff]
* strategies and automatic request re-scheduling.
*
* Using a suspending function (rather than a callback-based API) allows for easier composition of
* multiple requests together, since coroutine builders (such as [kotlinx.coroutines.async] and
* [kotlinx.coroutines.launch]) can be used to compose parallel requests.
*
* Additionally, error handling can be treated as if the code was sequential.
*/
class RequestManager {
/**
* A sealed class representing a successful response for the request, or an indication that the
* request was cancelled by the [Backoff] strategy.
*
* We may also want to use [kotlin.Result] when the ABI for inline classes is stable.
*/
sealed class ResponseOrCancelled {
data class Success(val response: Response) : ResponseOrCancelled()
object Cancelled : ResponseOrCancelled()
}
/**
* An interface defining a [Backoff] strategy. A [Backoff] is responsible for the scheduling of
* a certain call to the API.
*
* When scheduled, a [Backoff] should specify a [Result] after executing. This result indicates
* whether the response was a success, if the [Backoff] decided to cancel the request or if the
* request should be rescheduled. If rescheduled, the [Backoff.schedule] method will be called
* again.
*
* A benefit of using a suspending [Backoff] API is that [Backoff]s can await certain conditions
* to be true before resuming. In particular, we may be interested in listening for some
* system services (such as the [android.net.ConnectivityManager] before triggering a request.
*/
interface Backoff {
/**
* A sealed class representing the result of the [Backoff.schedule] call.
*/
sealed class Result {
data class Success(val response: Response) : Result()
object Cancel : Result()
object Reschedule : Result()
}
/**
* Schedules the execution of a certain [call], for a provided [request]. The scheduling
* might be dependent on the kind of [Request] we're making.
*
* @param request the [Request] that should be scheduled and executed.
* @param call the suspending function to use to perform the [Request].
*
* @return a [Result] indicating how the execution of the request went, and if retry is
* needed.
*/
suspend fun schedule(
request: Request,
call: suspend (Request) -> Response?,
): Result
// For extension functions builders.
companion object
}
/**
* Performs a new [Request], with a specified [Backoff] policy.
*
* @param request the request to perform.
* @param backoff the [Backoff] policy to apply.
*
* @return a [ResponseOrCancelled] that indicates how the response went. We could use a
* [kotlin.Result] once the inline classes ABI is stable.
*/
suspend fun request(
request: Request,
backoff: Backoff = Backoff.Once,
): ResponseOrCancelled {
val perform: suspend (Request) -> Response? = {
withContext(Dispatchers.IO) {
TODO("This is where our UrlConnection implementation would go.")
}
}
while (true) {
when (val result = backoff.schedule(request, perform)) {
is Backoff.Result.Success -> {
return Success(result.response)
}
Backoff.Result.Cancel -> {
return Cancelled
}
Backoff.Result.Reschedule -> {
/* Ignored. */
}
}
}
}
/**
* A data class representing what we want our requests to do.
*/
data class Request(
val resource: String,
val headers: Map<String, List<String>> = emptyMap(),
val content: String? = null,
// ...
)
/**
* A data class representing what we want our responses to look like.
*/
data class Response(
val status: Int,
val headers: Map<String, List<String>>,
val content: String,
// ...
)
}
/**
* Creates a new [Backoff] that does not reschedule a failed [RequestManager.Request].
*/
val Backoff.Companion.Once: Backoff
get() = object : Backoff {
override suspend fun schedule(
request: RequestManager.Request,
call: suspend (RequestManager.Request) -> RequestManager.Response?,
): Backoff.Result {
return when (val response = call(request)) {
null -> Backoff.Result.Cancel
else -> Backoff.Result.Success(response)
}
}
}
/**
* Creates a new [Backoff] that will always be re-scheduled for the same amount of time.
*/
fun Backoff.Companion.repeat(every: Long): Backoff = object : Backoff {
var first = true
override suspend fun schedule(
request: RequestManager.Request,
call: suspend (RequestManager.Request) -> RequestManager.Response?
): Backoff.Result {
val delay = if (first) 0 else every
delay(delay)
first = false
return when (val response = call(request)) {
null -> Backoff.Result.Reschedule
else -> Backoff.Result.Success(response)
}
}
}
package ch.heigvd.iict.sym.labo2.betterComm
import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import ch.heigvd.iict.sym.labo2.betterComm.RequestManager.ResponseOrCancelled.Cancelled
import ch.heigvd.iict.sym.labo2.betterComm.RequestManager.ResponseOrCancelled.Success
import com.example.labo2.R
import kotlinx.coroutines.launch
/**
* An example usage of the "better" communication API.
*/
class UsageSample : AppCompatActivity() {
private val manager = RequestManager()
private val request = RequestManager.Request(resource = "https://www.google.com")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity)
val textView = findViewById<TextView>(android.R.id.text1)
// Requests are tied to the lifecycle of our Activity. If it gets destroyed, our coroutines
// will be cancelled.
lifecycleScope.launch {
// Make a request with a synchronous-looking API.
val r = manager.request(
request = request,
backoff = RequestManager.Backoff.Once,
)
// Handle errors with a dedicated type.
when (r) {
is Success -> textView.text = r.response.content
Cancelled -> textView.text = "Cancelled."
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment