microsoft / BotFramework-DirectLineJS

JavaScript client library for Microsoft Bot Framework's Direct Line protocol
MIT License
196 stars 123 forks source link

Application crashes when refresh token returns an error #421

Open orgads opened 5 months ago

orgads commented 5 months ago

If tokens/refresh returns an error, the application crashes, and there is no way to catch and handle it properly.

Reproduced by:

import { DirectLine, ConnectionStatus } from 'botframework-directlinejs';
import WebSocket, { WebSocketServer } from 'ws';
import nock from 'nock';
import xhr2 from 'xhr2';

global.XMLHttpRequest = xhr2;
global.WebSocket = WebSocket;

const wss = new WebSocketServer({ port: 2222 });
wss.on('connection', function connection(ws) {
  ws.on('error', console.error);
  ws.send('{"activities": []}');
});

nock('https://directline.botframework.com')
.persist()
.post(uri => uri.startsWith('/v3/directline/conversations'))
.reply(
  200,
  JSON.stringify({
    conversationId: '123',
    token: '456',
    streamUrl: 'ws://localhost:2222'
  })
)
.post(uri => uri.includes('/refresh'))
.reply(403);

const directLine = new DirectLine({ token: '456' });
directLine.activity$
.subscribe(
  (activity) => console.log('Activity received: ', activity),
  (err) => console.error('**1 Error: ', err)
);
directLine.connectionStatus$
.subscribe(
  (connectionStatus) => console.log('DirectLine status: ' + ConnectionStatus[connectionStatus]),
  (err) => console.error('**2 Error: ', err)
);

process.on('uncaughtException', (err) => {
  console.error('**3 Error: ', err);
  process.exit(1);
});

If you don't want to wait 15 minutes, reduce the hardcoded value of lifetimeRefreshToken :)

Output:

DirectLine status: Connecting
DirectLine status: Online
DirectLine status: ExpiredToken
DirectLine status: ExpiredToken
**3 Error:  [AjaxError: ajax error 403] {
  xhr: <ref *1> XMLHttpRequest {
    onloadstart: null,
    onprogress: null,
    onabort: null,
    onerror: null,
    onload: null,
    ontimeout: [Function: xhrTimeout] {
      request: [Object],
      subscriber: [AjaxSubscriber],
      progressSubscriber: undefined
    },
    onloadend: null,
    _listeners: {},
    onreadystatechange: [Function: xhrReadyStateChange] {
      subscriber: [AjaxSubscriber],
      progressSubscriber: undefined,
      request: [Object]
    },
    _anonymous: undefined,
    readyState: 4,
    response: null,
    responseText: null,
    responseType: 'json',
    responseURL: 'https://directline.botframework.com/v3/directline/tokens/refresh',
    status: 403,
    statusText: 'Forbidden',
...
stevkan commented 1 month ago

@compulim, @OEvgeny

OEvgeny commented 1 month ago

Thanks for investigating and for the repro. As a user-land solution you could also consider extending the DirectLine class with own refresh token implementation.

I've analyzed the issue and have a proposal for addressing it (cc @compulim):

Current Issue

The application crashes when the token refresh fails (e.g., with a 403 error). This occurs in the refreshTokenLoop method, which is called from checkConnection. As the refresh token loop doesn't interfere with connectionStatus$ subject, the error doesn't get propagated:

https://github.com/microsoft/BotFramework-DirectLineJS/blob/bb3d7eec6cad81569032567c99a567b7d4b462fc/src/directLine.ts#L564-L565

I see why this might be done on-purpose. The cause looks like the one we cannot recover direct line from, so trying to crash the runtime to give both: visibility and the ability to restart the whole process seems valid.

Proposal

We can probably enhance error handling though without changing the public API e.g. to allow replacing the DirctLine with a new one:

  1. Modify refreshTokenLoop to catch errors and continue the loop:

    private refreshTokenLoop() {
     this.tokenRefreshSubscription = Observable.interval(intervalRefreshToken)
       .flatMap(_ => this.refreshToken()
         .pipe(
           catchError(error => {
             this.handleFatalError(error);
             return Observable.empty();
           })
         )
       )
       .subscribe(/* ... */);
    }
  2. Update refreshToken to simplify error handling:

    private refreshToken(): Observable<string> {
     return this.checkConnection(true)
       .flatMap(/* ... */)
       .pipe(
         catchError(error => {
           if (error.status === 403 || error.status === 404) {
             return Observable.throw(error);
           }
           return Observable.throw(error).pipe(
             retryWhen(/* ... */)
           );
         })
       );
    }
  3. Add handleFatalError to emit an error through connectionStatus$:

    private handleFatalError(error) {
     this.connectionStatus$.error(error);
    }

While addressing the issue this seems to:

Usage

Users would be able to catch token expiration errors as follows:

directLine.connectionStatus$.subscribe(
  status => console.log('Status:', ConnectionStatus[status]),
  error => {
    console.error('Error:', error);
    // Implement recovery logic here
  }
);

This proposal could be considered a minor version change as it adds functionality without breaking existing API contracts, but also we do not crash as before, so this may be considered as a breaking change as well. We might be able to work further towards more compatible solution e.g. by checking the observers for error handling.

Any thoughts?