Streaming: refactor to custom Error classes (#28632)
Co-authored-by: Renaud Chaput <renchap@gmail.com> Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
		
							
								
								
									
										51
									
								
								streaming/errors.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								streaming/errors.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,51 @@
 | 
			
		||||
// @ts-check
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Typed as a string because otherwise it's a const string, which means we can't
 | 
			
		||||
 * override it in let statements.
 | 
			
		||||
 * @type {string}
 | 
			
		||||
 */
 | 
			
		||||
const UNEXPECTED_ERROR_MESSAGE = 'An unexpected error occurred';
 | 
			
		||||
exports.UNKNOWN_ERROR_MESSAGE = UNEXPECTED_ERROR_MESSAGE;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Extracts the status and message properties from the error object, if
 | 
			
		||||
 * available for public use. The `unknown` is for catch statements
 | 
			
		||||
 * @param {Error | AuthenticationError | RequestError | unknown} err
 | 
			
		||||
 */
 | 
			
		||||
exports.extractStatusAndMessage = function(err) {
 | 
			
		||||
  let statusCode = 500;
 | 
			
		||||
  let errorMessage = UNEXPECTED_ERROR_MESSAGE;
 | 
			
		||||
  if (err instanceof AuthenticationError || err instanceof RequestError) {
 | 
			
		||||
    statusCode = err.status;
 | 
			
		||||
    errorMessage = err.message;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return { statusCode, errorMessage };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class RequestError extends Error {
 | 
			
		||||
  /**
 | 
			
		||||
   * @param {string} message
 | 
			
		||||
   */
 | 
			
		||||
  constructor(message) {
 | 
			
		||||
    super(message);
 | 
			
		||||
    this.name = "RequestError";
 | 
			
		||||
    this.status = 400;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
exports.RequestError = RequestError;
 | 
			
		||||
 | 
			
		||||
class AuthenticationError extends Error {
 | 
			
		||||
  /**
 | 
			
		||||
   * @param {string} message
 | 
			
		||||
   */
 | 
			
		||||
  constructor(message) {
 | 
			
		||||
    super(message);
 | 
			
		||||
    this.name = "AuthenticationError";
 | 
			
		||||
    this.status = 401;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
exports.AuthenticationError = AuthenticationError;
 | 
			
		||||
@@ -14,6 +14,8 @@ const pg = require('pg');
 | 
			
		||||
const dbUrlToConfig = require('pg-connection-string').parse;
 | 
			
		||||
const WebSocket = require('ws');
 | 
			
		||||
 | 
			
		||||
const errors = require('./errors');
 | 
			
		||||
const { AuthenticationError, RequestError } = require('./errors');
 | 
			
		||||
const { logger, httpLogger, initializeLogLevel, attachWebsocketHttpLogger, createWebsocketLogger } = require('./logging');
 | 
			
		||||
const { setupMetrics } = require('./metrics');
 | 
			
		||||
const { isTruthy, normalizeHashtag, firstParam } = require("./utils");
 | 
			
		||||
@@ -324,7 +326,7 @@ const startServer = async () => {
 | 
			
		||||
      // Unfortunately for using the on('upgrade') setup, we need to manually
 | 
			
		||||
      // write a HTTP Response to the Socket to close the connection upgrade
 | 
			
		||||
      // attempt, so the following code is to handle all of that.
 | 
			
		||||
      const statusCode = err.status ?? 401;
 | 
			
		||||
      const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
 | 
			
		||||
 | 
			
		||||
      /** @type {Record<string, string | number | import('pino-http').ReqId>} */
 | 
			
		||||
      const headers = {
 | 
			
		||||
@@ -332,7 +334,7 @@ const startServer = async () => {
 | 
			
		||||
        'Content-Type': 'text/plain',
 | 
			
		||||
        'Content-Length': 0,
 | 
			
		||||
        'X-Request-Id': request.id,
 | 
			
		||||
        'X-Error-Message': err.status ? err.toString() : 'An unexpected error occurred'
 | 
			
		||||
        'X-Error-Message': errorMessage
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Ensure the socket is closed once we've finished writing to it:
 | 
			
		||||
@@ -350,7 +352,7 @@ const startServer = async () => {
 | 
			
		||||
          statusCode,
 | 
			
		||||
          headers
 | 
			
		||||
        }
 | 
			
		||||
      }, err.toString());
 | 
			
		||||
      }, errorMessage);
 | 
			
		||||
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
@@ -535,11 +537,7 @@ const startServer = async () => {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (result.rows.length === 0) {
 | 
			
		||||
          err = new Error('Invalid access token');
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          err.status = 401;
 | 
			
		||||
 | 
			
		||||
          reject(err);
 | 
			
		||||
          reject(new AuthenticationError('Invalid access token'));
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -570,11 +568,7 @@ const startServer = async () => {
 | 
			
		||||
    const accessToken   = location.query.access_token || req.headers['sec-websocket-protocol'];
 | 
			
		||||
 | 
			
		||||
    if (!authorization && !accessToken) {
 | 
			
		||||
      const err = new Error('Missing access token');
 | 
			
		||||
      // @ts-ignore
 | 
			
		||||
      err.status = 401;
 | 
			
		||||
 | 
			
		||||
      reject(err);
 | 
			
		||||
      reject(new AuthenticationError('Missing access token'));
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -651,11 +645,7 @@ const startServer = async () => {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const err = new Error('Access token does not cover required scopes');
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    err.status = 401;
 | 
			
		||||
 | 
			
		||||
    reject(err);
 | 
			
		||||
    reject(new AuthenticationError('Access token does not have the required scopes'));
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@@ -731,11 +721,7 @@ const startServer = async () => {
 | 
			
		||||
    // If no channelName can be found for the request, then we should terminate
 | 
			
		||||
    // the connection, as there's nothing to stream back
 | 
			
		||||
    if (!channelName) {
 | 
			
		||||
      const err = new Error('Unknown channel requested');
 | 
			
		||||
      // @ts-ignore
 | 
			
		||||
      err.status = 400;
 | 
			
		||||
 | 
			
		||||
      next(err);
 | 
			
		||||
      next(new RequestError('Unknown channel requested'));
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -762,10 +748,7 @@ const startServer = async () => {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const hasStatusCode = Object.hasOwnProperty.call(err, 'status');
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    const statusCode = hasStatusCode ? err.status : 500;
 | 
			
		||||
    const errorMessage = hasStatusCode ? err.toString() : 'An unexpected error occurred';
 | 
			
		||||
    const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
 | 
			
		||||
 | 
			
		||||
    res.writeHead(statusCode, { 'Content-Type': 'application/json' });
 | 
			
		||||
    res.end(JSON.stringify({ error: errorMessage }));
 | 
			
		||||
@@ -1140,7 +1123,7 @@ const startServer = async () => {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * @param {any} res
 | 
			
		||||
   * @param {http.ServerResponse} res
 | 
			
		||||
   */
 | 
			
		||||
  const httpNotFound = res => {
 | 
			
		||||
    res.writeHead(404, { 'Content-Type': 'application/json' });
 | 
			
		||||
@@ -1155,16 +1138,29 @@ const startServer = async () => {
 | 
			
		||||
  api.use(errorMiddleware);
 | 
			
		||||
 | 
			
		||||
  api.get('/api/v1/streaming/*', (req, res) => {
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    channelNameToIds(req, channelNameFromPath(req), req.query).then(({ channelIds, options }) => {
 | 
			
		||||
    const channelName = channelNameFromPath(req);
 | 
			
		||||
 | 
			
		||||
    // FIXME: In theory we'd never actually reach here due to
 | 
			
		||||
    // authenticationMiddleware catching this case, however, we need to refactor
 | 
			
		||||
    // how those middlewares work, so I'm adding the extra check in here.
 | 
			
		||||
    if (!channelName) {
 | 
			
		||||
      httpNotFound(res);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    channelNameToIds(req, channelName, req.query).then(({ channelIds, options }) => {
 | 
			
		||||
      const onSend = streamToHttp(req, res);
 | 
			
		||||
      const onEnd = streamHttpEnd(req, subscriptionHeartbeat(channelIds));
 | 
			
		||||
 | 
			
		||||
      // @ts-ignore
 | 
			
		||||
      streamFrom(channelIds, req, req.log, onSend, onEnd, 'eventsource', options.needsFiltering);
 | 
			
		||||
    }).catch(err => {
 | 
			
		||||
      res.log.info({ err }, 'Subscription error:', err.toString());
 | 
			
		||||
      httpNotFound(res);
 | 
			
		||||
      const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
 | 
			
		||||
 | 
			
		||||
      res.log.info({ err }, 'Eventsource subscription error');
 | 
			
		||||
 | 
			
		||||
      res.writeHead(statusCode, { 'Content-Type': 'application/json' });
 | 
			
		||||
      res.end(JSON.stringify({ error: errorMessage }));
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@@ -1265,8 +1261,8 @@ const startServer = async () => {
 | 
			
		||||
 | 
			
		||||
      break;
 | 
			
		||||
    case 'hashtag':
 | 
			
		||||
      if (!params.tag || params.tag.length === 0) {
 | 
			
		||||
        reject('No tag for stream provided');
 | 
			
		||||
      if (!params.tag) {
 | 
			
		||||
        reject(new RequestError('Missing tag name parameter'));
 | 
			
		||||
      } else {
 | 
			
		||||
        resolve({
 | 
			
		||||
          channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}`],
 | 
			
		||||
@@ -1276,8 +1272,8 @@ const startServer = async () => {
 | 
			
		||||
 | 
			
		||||
      break;
 | 
			
		||||
    case 'hashtag:local':
 | 
			
		||||
      if (!params.tag || params.tag.length === 0) {
 | 
			
		||||
        reject('No tag for stream provided');
 | 
			
		||||
      if (!params.tag) {
 | 
			
		||||
        reject(new RequestError('Missing tag name parameter'));
 | 
			
		||||
      } else {
 | 
			
		||||
        resolve({
 | 
			
		||||
          channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}:local`],
 | 
			
		||||
@@ -1287,19 +1283,23 @@ const startServer = async () => {
 | 
			
		||||
 | 
			
		||||
      break;
 | 
			
		||||
    case 'list':
 | 
			
		||||
      // @ts-ignore
 | 
			
		||||
      if (!params.list) {
 | 
			
		||||
        reject(new RequestError('Missing list name parameter'));
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      authorizeListAccess(params.list, req).then(() => {
 | 
			
		||||
        resolve({
 | 
			
		||||
          channelIds: [`timeline:list:${params.list}`],
 | 
			
		||||
          options: { needsFiltering: false },
 | 
			
		||||
        });
 | 
			
		||||
      }).catch(() => {
 | 
			
		||||
        reject('Not authorized to stream this list');
 | 
			
		||||
        reject(new AuthenticationError('Not authorized to stream this list'));
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      break;
 | 
			
		||||
    default:
 | 
			
		||||
      reject('Unknown stream type');
 | 
			
		||||
      reject(new RequestError('Unknown stream type'));
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@@ -1353,8 +1353,17 @@ const startServer = async () => {
 | 
			
		||||
        stopHeartbeat,
 | 
			
		||||
      };
 | 
			
		||||
    }).catch(err => {
 | 
			
		||||
      logger.error({ err }, 'Subscription error');
 | 
			
		||||
      websocket.send(JSON.stringify({ error: err.toString() }));
 | 
			
		||||
      const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
 | 
			
		||||
 | 
			
		||||
      logger.error({ err }, 'Websocket subscription error');
 | 
			
		||||
 | 
			
		||||
      // If we have a socket that is alive and open still, send the error back to the client:
 | 
			
		||||
      if (websocket.isAlive && websocket.readyState === websocket.OPEN) {
 | 
			
		||||
        websocket.send(JSON.stringify({
 | 
			
		||||
          error: errorMessage,
 | 
			
		||||
          status: statusCode
 | 
			
		||||
        }));
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -1393,10 +1402,11 @@ const startServer = async () => {
 | 
			
		||||
    channelNameToIds(request, channelName, params).then(({ channelIds }) => {
 | 
			
		||||
      removeSubscription(session, channelIds);
 | 
			
		||||
    }).catch(err => {
 | 
			
		||||
      logger.error({err}, 'Unsubscribe error');
 | 
			
		||||
      logger.error({err}, 'Websocket unsubscribe error');
 | 
			
		||||
 | 
			
		||||
      // If we have a socket that is alive and open still, send the error back to the client:
 | 
			
		||||
      if (websocket.isAlive && websocket.readyState === websocket.OPEN) {
 | 
			
		||||
        // TODO: Use a better error response here
 | 
			
		||||
        websocket.send(JSON.stringify({ error: "Error unsubscribing from channel" }));
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user