Hi All, I hope you're doing well! I'm having a b...
# store
s
Hi All, I hope you're doing well! I'm having a bit of trouble with
5.1.0-alpha02
and was wondering if anyone could help? I followed [this](https://www.droidcon.com/2023/08/01/intercepting-ktor-network-responses-in-kotlin-multiplatform/) advise of adding a safe get method which returns a result type with either data or an error type in order to handle the situation when the device does not have an internet connection causing a
<http://java.net|java.net>.UnknownHostException
. This is based on the following from the documentation:
When an error happens, Store does not throw an exception, instead, it wraps it in a StoreResponse.Error type which allows Flow to continue so that it can still receive updates that might be triggered by either changes in your data source or subsequent fetch operations.
So if I throw an exception here like this:
Copy code
kotlin
override fun provide(): Store<Int, List<Pokemon>> {
        return StoreBuilder
            .from(
                fetcher = Fetcher.of { _: Int ->
                    when (val pokemon =
                        pokemonService.getPokemon(offset = 0, limit = PokemonService.pageSize)) {
                        is ApiResponse.Success -> {
                            pokemon.body.results.mapIndexed { index, pokemon ->
                                Pokemon(
                                    index,
                                    pokemon.name
                                )
                            }
                        }
                        is ApiResponse.Error.HttpError -> throw Throwable("${pokemon.errorMessage}")
                        is ApiResponse.Error.GenericError -> throw Throwable("${pokemon.errorMessage}")
                        is ApiResponse.Error.SerializationError -> throw Throwable("${pokemon.errorMessage}")
                    }

                },
                sourceOfTruth = SourceOfTruth.of(
                    reader = { pokemonDao.selectAll() },
                    writer = { page, pokemon -> pokemonDao.insertAll(pokemon, page) },
                    delete = { page -> pokemonDao.delete(page) },
                    deleteAll = { pokemonDao.deleteAll() },
                )
            )
            .build()
    }
...I should be able to parse the
StoreReadResponse.Error
when I collect the results and this will not interrupt the flow:
Copy code
kotlin
pokemonStore.stream(StoreReadRequest.cached(key = page, refresh = false))
                .collect { response ->
                    pokemonListUiState.value = when (response) {
                        ... //handling other cases here omitted for brevity
                        is StoreReadResponse.Error -> {
                            //update UI with read error
                        }
                        ...
                    }
                }
Currently when I get an error and throw it there it will throw the exception there and it is not propagated and this causes the app to crash with the exception thrown.
Copy code
FATAL EXCEPTION: main
    Process: com.king.pokestore, PID: 3172
    java.lang.Throwable: Unable to resolve host "<http://pokeapi.co|pokeapi.co>": No address associated with hostname
    at com.king.pokestore.feature.pokemonlist.PokemonListStoreProvider$provide$1.invokeSuspend(PokemonListStoreProvider.kt:32) //this is the line where I throw the GenericError type
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
    Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@8e19663, Dispatchers.Main.immediate]
Am I doing something wrong to prevent the error from propagating? Some help would be greatly appreciated! :)
m
Hey there - The fetcher factory expects
FetcherResult
, which can hold data or an error. In your fetcher binding, convert the caught error to a
FetcherResult.Error
. Here's an example: https://github.com/MobileNativeFoundation/Store/discussions/631#discussioncomment-8814038
s
Hi Matthew thanks for your response I've added this! What would my writer look like now? Currently I'm just using my DAO to update the database when I retrieve new results. But as we are now taking FetcherResult do I need to handle all of the different results of FetcherResult in the writer? Something like this?
Copy code
writer = { page, pokemon ->
            when(pokemon){
                is FetcherResult.Data -> pokemonDao.insertAll(pokemon.value, page)
                else -> {
                    Timber.e("Error retrieving data")
                }
            } },
I've implemented this and while it does handle the error I've noticed the error still doesn't propagate the error to my ViewModel so I end up getting to the no new data stage without handling the error.
m
Hey Sam, sorry to be slow. I'll take a closer look tn and get back to you
Hey Sam, sorry again to be slow. I just tried to repro. I misunderstood your issue earlier - you do not need to return a
FetcherResult
. Any thrown error should be caught and propagated by Store. Below is a working sample. Take a look and let me know if you notice any setup differences between mine and yours. Also happy to review a full implementation if you can share it
Copy code
@Inject
class UserStoreFactory(
    private val networkingClient: NetworkingClient
) {
    fun create(): UserStore {
        val storeBuilder = StoreBuilder.from<GetUserQuery, GetUserQuery.User, GetUserQuery.User>(
            fetcher = Fetcher.of { query: GetUserQuery ->
                throw Throwable("TEST")
            },
            sourceOfTruth = SourceOfTruth.of<GetUserQuery, GetUserQuery.User, GetUserQuery.User>(
                reader = {
                    flowOf(GetUserQuery.User("", null, "", GetUserQuery.Repositories(emptyList())))
                },
                writer = { _, __ ->
                }
            )
        )

        return storeBuilder.build()
    }
}

// Sample Usage (do not recommend using Store in a launched effect in real code)
LaunchedEffect(Unit) {
                        try {

                            coreComponent.userStore.stream(StoreReadRequest.fresh(GetUserQuery("matt-ramotar"))).collect {
                                println("STORE RESPONSE = $it")
                            }

                        } catch (error: Throwable) {
                            println("ERROR NOT PROPAGATED BY STORE = $error")
                        }
                    }

// Output

STORE RESPONSE = Loading(origin=Fetcher(name=null))
STORE RESPONSE = Exception(error=java.lang.Throwable: TEST, origin=Fetcher(name=null))
s
Hi Matthew apologies it has been a long time! Been a manic couple of weeks! I tried wrapping my store fetch in a try catch and this still doesn't seem to fix the issue. Here is my full code:
Copy code
class PokemonListStoreProvider(
    private val pokemonService: PokemonService,
    private val pokemonDao: PokemonDao,
) : StoreProvider<Int, List<Pokemon>> {

    override fun provide(): Store<Int, List<Pokemon>> {
        return StoreBuilder
            .from(
                fetcher = Fetcher.of { _: Int ->
                    when (val pokemon =
                        pokemonService.getPokemon(offset = 0, limit = PokemonService.pageSize)) {
                        is ApiResponse.Success -> {
                            val results = pokemon.body.results.mapIndexed { index, pokemonDto ->
                                Pokemon(
                                    index,
                                    pokemonDto.name
                                )
                            }
                            FetcherResult.Data(results)
                        }
                        is ApiResponse.Error.HttpError -> throw Throwable("${pokemon.errorMessage}")
                        is ApiResponse.Error.GenericError -> throw Throwable("${pokemon.errorMessage}") // this is the line where my exception is thrown.
                        is ApiResponse.Error.SerializationError -> throw Throwable("${pokemon.errorMessage}")
                    }

                },
                sourceOfTruth = SourceOfTruth.of(
                    reader = { pokemonDao.selectAll() },
                    writer = { page, pokemon ->
                        pokemonDao.insertAll(pokemon.value, page)
                    },
                    delete = { page -> pokemonDao.delete(page) },
                    deleteAll = { pokemonDao.deleteAll() },
                )
            )
            .build()
    }
}
Copy code
class PokemonListViewModel(
    private val pokemonStore: Store<Int, List<Pokemon>>,
) : ViewModel() {

    var pokemonListUiState: MutableStateFlow<PokemonListUIState> =
        MutableStateFlow(PokemonListUIState())
        private set

    init {
        viewModelScope.launch {
            val page = pokemonListUiState.value.page
            Timber.i("Loading Pokemon list for page: $page")
            try {
                pokemonStore.stream(StoreReadRequest.fresh(key = page))
                    .collect { response ->
                        pokemonListUiState.value = when (response) {
                            is StoreReadResponse.Loading -> {
                                Timber.i("[State] ${UiState.Loading}")
                                pokemonListUiState.value.copy(
                                    state = UiState.Loading,
                                    error = "",
                                )
                            }

                            is StoreReadResponse.Data -> {
                                Timber.i("[State] ${UiState.NoNewData}")
                                pokemonListUiState.value.copy(
                                    state = UiState.NoNewData,
                                    pokemon = response.value,
                                    endReached = response.value.isEmpty(),
                                    error = "",
                                )
                            }

                            is StoreReadResponse.NoNewData -> {
                                Timber.i("[State] ${UiState.NoNewData}")
                                pokemonListUiState.value.copy(
                                    state = UiState.NoNewData,
                                    endReached = true,
                                    error = "",
                                )
                            }

                            is StoreReadResponse.Error -> {
                                Timber.i("[State] ${UiState.Error}")
                                pokemonListUiState.value.copy(
                                    state = UiState.Error,
                                    error = response.errorMessageOrNull() ?: "Unknown error",
                                )
                            }

                            StoreReadResponse.Initial -> {
                                Timber.i("[State] ${UiState.Idle}")
                                pokemonListUiState.value.copy(
                                    state = UiState.Loading,
                                    error = "",
                                )
                            }
                        }

                    }
            } catch (e: Exception) { //this doesn't catch the exception
                e.message?.let {
                    pokemonListUiState.value.copy(
                        state = UiState.Error,
                        error = it
                    )
                }
            }

        }
    }

    fun loadMore() {
        ...
    }

    fun refresh() {
        ...
    }
}
Copy code
17:37:00.430  E  FATAL EXCEPTION: main
                 Process: com.king.pokestore, PID: 5387
                 java.lang.Throwable: Unable to resolve host "<http://pokeapi.co|pokeapi.co>": No address associated with hostname
                 	at com.king.pokestore.feature.pokemonlist.PokemonListStoreProvider$provide$1.invokeSuspend(PokemonListStoreProvider.kt:34)
                 	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
                 	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
                 	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
                 	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
                 	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
                 	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
                 	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@10c559b, Dispatchers.Main.immediate]
b
I've been experiencing this issue also, but I'm not entirely sure it's an issue with store. I'm very clearly seeing the
.catching { ... }
in the fetcher but for some reason it's not catching it. I was digging through coroutine code all night to figure it out.
m
Are you catching and rethrowing in Fetcher too? I wonder if there’s an issue catching with catching rethrown exceptions
It looks like Sam is catching and rethrowing
b
I tried that too. I caught the exception which was a standard java exception and then threw a generic
Throwable
and it still wasn’t catching it and was crashing it instead. It’s pretty simple fetcher code
Copy code
Fetcher.of(
  fetch = { key: EventKey ->
    require(key is EventKey.Read)
    when (key) {
      is EventKey.Read.ByEventId -> api.getEventById(key.eventId)
      EventKey.Read.All -> api.getEvents().run {
        NetworkEvent(EventData.Collection(this), null)
      }
    }
  }
)
Or with the catch my test code looked like this but it was still crashing the application
Copy code
Fetcher.of(
  fetch = { key: EventKey ->
    require(key is EventKey.Read)
    when (key) {
      is EventKey.Read.ByEventId -> api.getEventById(key.eventId)
      EventKey.Read.All -> runCatching {
        api.getEvents().run {
          NetworkEvent(EventData.Collection(this), null)
        }
      }.recoverCatching { it: Throwable ->
        throw Throwable()
      }.getOrThrow()
    }
  }
)
I’m beginning to think it might be a Kotlin thing rather than a store thing
I've had some truly weird coroutines glitches in the past where something like this didn't work
Copy code
result = someCoroutine()
but this would
Copy code
val response = someCoroutine()
result = response
m
Hmm weird, where do you need to add a try catch block to prevent crash? At the Store.stream call?
b
Yeah, that's the only place it was working
I'm gonna test on a device rather than emulator as well as iOS and see if there's something there
m
A few qs - if you have a repro, I could review that instead • Is this happening with MutableStore? Or Store? Or both? • Are you providing a custom scope in the builder? • What does your code look like for making the read request? Curious about coroutine scopes mostly
b
1. This is happening with a MutableStore 2. I’m not sure what you mean but I’m inclined to say no
Copy code
MutableStoreBuilder.from(
  fetcher = fetcher,
  sourceOfTruth = sourceOfTruth,
  converter = converter
).build(updater, bookkeeper)
3. The read request is a Ktor
HttpClient
backed by OkHttp for Android. No specific dispatcher is set and I’m not using
withContext(…) { … }
for the request. Just a straight
KtorClient.get
m
So if you make the same failing request with normal Store, it doesn’t crash?
b
I haven't tested with a regular store. OP above seems to have been using a regular store
m
Created an issue https://github.com/MobileNativeFoundation/Store/issues/668 for tracking purposes
Repro code would be super helpful, I haven't been able to repro
b
I'm currently trying to isolate the issue
I figured it out! The issue was with
store.fresh
It filters out New and NoNewData, gets the first element, then expects a result. In my case I wasn’t catching the exception so it would crash. It would never point to that call sight as the point of the issue which made it hard to find. The person above seems to be using stream so it might very well be a different issue
👀 1
m
Interesting, thanks for figuring this out!
b
It might be a good area for a comment saying exceptions aren’t handled
m
Yeah agreed we should. Happy to stamp a PR if you can get to it. I’m traveling currently but can otherwise get one up next week sometime
b
I have this on my todo list