Jammer
Jammer

Reputation: 10208

Bot Framework v4.2 - Gacefully Recover From OnTurnError Exception

I've been reading around the docs and looking at code samples for hints and inspiration and so far I've turned up nothing.

If we catch a global exception occuring in our bots we enter the OnTurnError handler:

// Catches any errors that occur during a conversation turn and logs them.
options.OnTurnError = async (context, exception) =>
{
    logger.LogError($"Exception caught : {exception}");
    await context.SendActivityAsync("Sorry, it looks like something went wrong.");
};

I haven't found anything in the docs or in any discussions where anyone is subsequently recovering from an error and restarting the conversation. I have a solution working but I'm wondering if I'm missing a more "best practice" method. I'm just doing this at the moment:

options.OnTurnError = async (context, exception) =>
{
    logger.LogError($"Exception caught : {exception}");
    await context.SendActivityAsync("Sorry, it looks like something went wrong.");

    await _conversationState.DeleteAsync(context);

    await MyBot.SendIntroCardAsync(context, CancellationToken.None);
};

Without some kind of recovery like this we are leaving users stranded in dead conversations. Is there a better solution I'm not finding?

Upvotes: 4

Views: 675

Answers (2)

Piros
Piros

Reputation: 11

I'm using the Python version of the SDK and what worked for me was the on_turn method: Olace a try-except and after except to clear conversation:

async def on_turn(self, turn_context: TurnContext):
    try:
        await super().on_turn(turn_context)

        if self.state_profile_accessor.get(turn_context, CustomState) is not None:
            state_profile = await self.state_profile_accessor.get(turn_context, CustomState)

    except Exception as e:
        await self.conversation_state.clear_state(turn_context)
        await self.profile_state.clear_state(turn_context)
        # await self.profile_state.delete(turn_context)
        # await self.conversation_state.profile_state.delete(turn_context)
        time.sleep(2)
        message = MessageFactory.attachment("resetting")
        await turn_context.send_activity(message)
        await self.conversation_state.save_changes(turn_context)
        await self.profile_state.save_changes(turn_context)

Anyway I'm using a my own Custom State, but the .clear_state & .save_changes restarts it ok for my use-case. Hope this helps.

Upvotes: 1

Drew Marsh
Drew Marsh

Reputation: 33379

"Wiping" Conversation State and starting over is certainly one way to handle it, albeit a bit heavy handed. Calling DeleteAsync is the right way to do that though. Perhaps consider that you don't want to wipe all conversation state and maybe just wipe the DialogState instead? You would do that by just calling IStatePropertyAccessor<DialogState>::DeleteAsync instead. All depends on what you're maintaining in state and where though.

Now, where I see a problem is in trying to trigger your bot from the OnTurnError handler to sort of instantly restart the conversation when this happens. It looks like you're calling a static method (SendIntroCardAsync), which I suppose works, but it's created some very tight coupling that makes me uncomfortable.

Part of me wants to suggest that, if your MyBot really wants to be so involved in this level of exception handling, then maybe put an actual try/catch into the OnTurnAsync of the bot itself that basically keeps the handling of the activity square in its domain of responsibility and able to cleanly contain and trigger the SendIntroCardAsync. Then, in that case, the OnTurnError likely would never be hit unless the bot failed to handle the exception correctly. I would still keep everything in that handler logic except the call to SendIntroCardAsync, but now you know that's only ever going to be triggered in the scenarios where the bot fails to handle the exception correctly (which should be ultra-rare) or a piece of upstream middleware throws an exception. I don't really love this either though because it puts a responsibility into the bot that it could otherwise be agnostic of.

I think, given that, the final approach I would probably go with would be to build a specific piece of middleware that I would install at the "top" of the pipeline that does top level exception handling and, instead of knowing what exactly to call once it's done that handling, it actually just re-executes the entire pipeline again some configurable number of times. That way your downstream middleware and bot remain "dumb" from a top level exception handling perspective, yet you can still choose what to do (in your case wipe some state) by configuring a callback with this piece of middleware when you register it and, ultimately, your bot will see the replay of the turn once the exception has been handled as a fresh request. This middleware could even add something to the ITurnContext::TurnState bag that lets downstream logic detect that it's in a replay state and behave slightly differently. For example, imagine your bot being able to do this:

public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
{
   if(turnContext.IsReplayingTurnBecauseOfException())
   {
       ... send sorry msg and restart dialogs from wherever you want ...
   }
}

Where the IsReplayingTurnBecauseOfException (name up for debate) is an extension method for ITurnContext that would be provided along with this new piece of middleware to abstract code from reading TurnState details directly. Something like:

public static bool IsReplayingTurnBecauseOfException(this ITurnContext turnContext) =>
    turnContext.TurnState.ContainsKey("MySuperAwesomeExceptionHandlingMiddleware.IsReplayingBecauseOfException");

Upvotes: 4

Related Questions