How to properly report an error to client through WebSockets

7.3k views Asked by At

How do I properly close a websocket and and provide a clean, informative response to the client when an internal error occurs on my server? In my current case, the client must provide a parameter when it connects, and I am trying to handle incorrect or missing parameters received by OnOpen.

This example suggests I can just throw an exception in OnOpen, which will ultimately call OnError where I can close with a reason and message. It kinda works, but the client only receives an EOF, 1006, CLOSE_ABNORMAL.

Also, because I have found no other discussion, I can't tell what might be best practice.

I'm using the JSR-356 spec, as follows:

@ClientEndpoint
@ServerEndpoint(value="/ws/events/")
public class WebSocketEvents
{
    private javax.websocket.Session session;
    private long token;

    @OnOpen
    public void onWebSocketConnect(javax.websocket.Session session) throws BadRequestException
    {
        logger.info("WebSocket connection attempt: " + session);
        this.session = session;
        // this throws BadRequestException if null or invalid long
        // with short detail message, e.g., "Missing parameter: token"
        token = HTTP.getRequiredLongParameter(session, "token");
    }

    @OnMessage
    public void onWebSocketText(String message)
    {
        logger.info("Received text message: " + message);
    }

    @OnClose
    public void onWebSocketClose(CloseReason reason)
    {
        logger.info("WebSocket Closed: " + reason);
    }

    @OnError
    public void onWebSocketError(Throwable t)
    {
        logger.info("WebSocket Error: ");

        logger.debug(t, t);
        if (!session.isOpen())
        {
            logger.info("Throwable in closed websocket:" + t, t);
            return;
        }

        CloseCode reason = t instanceof BadRequestException ? CloseReason.CloseCodes.PROTOCOL_ERROR : CloseReason.CloseCodes.UNEXPECTED_CONDITION;
        try
        {
            session.close(new CloseReason(reason, t.getMessage()));
        }
        catch (IOException e)
        {
            logger.warn(e, e);
        }

    }
}

Edit: The exception throwing per linked example seems weird, so now I am catching exception within OnOpen and immediately doing

session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, "some text")); 

Edit: This turned out to be correct, though a separate bug disguised it for a while.


Edit2: Clarification: HTTP is my own static utility class. HTTP.getRequiredLongParameter() gets query parameters from the client's initial request by using

session.getRequestParameterMap().get(name)

and does further processing.

2

There are 2 answers

1
Saturn5 On BEST ANSWER

I believe I should have placed...

session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, "some text"));

...right where the error occurs, within @OnOpen(). (For generic errors, use CloseCodes.UNEXPECTED_CONDITION.)

The client receives:

onClose(1003, some text)

This is, of course, the obvious answer. I think I was misled, by the example cited, into throwing an exception from @OnOpen(). As Remy Lebeau suggested, the socket was probably closed by this, blocking any further handling by me in @OnError(). (Some other bug may have obscured the evidence that was discussed.)

1
Al-un On

To develop the points I mentioned, your problem about "how to handle a required parameter", I can see following options. First of all, let's consider the endpoint:

@ServerEndpoint(value = "/websocket/myendpoint", 
                configuration = MyWebsocketConfiguration.class)
public class MyEndpoint{
    // @OnOpen, @OnClose, @OnMessage, @OnError...
}

Filtering

The first contact between a client and a server is a HTTP request. You can filter it with a filter to prevent the websocket handshake from happening. A filter can either block a request or let it through:

import javax.servlet.Filter;

public class MyEndpointFilter implements Filter{
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        // nothing for this example
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // if the connection URL is /websocket/myendpoint?parameter=value
        // feel free to dig in what you can get from ServletRequest
        String myToken = request.getParameter("token");

        // if the parameter is mandatory
        if (myToken == null){
            // you can return an HTTP error code like:
            ((HttpServletResponse) response).setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        // if the parameter must match an expected value
        if (!isValid(myToken)){
            // process the error like above, you can
            // use the 403 HTTP status code for instance
            return;
        }

        // this part is very important: the filter allows
        // the request to keep going: all green and good to go!
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        //nothing for this example
    }

    private boolean isValid(String token){
         // how your token is checked? put it here
    }
}

If you are using a filter, you must add it in your web.xml:

<web-app ...>

    <!-- you declare the filter here -->
    <filter>
        <filter-name>myWebsocketFilter</filter-name>
        <filter-class>com.mypackage.MyEndpointFilter </filter-class>
        <async-supported>true</async-supported>
    </filter>
    <!-- then you map your filter to an url pattern. In websocket
         case, it must match the serverendpoint value -->
    <filter-mapping>
        <filter-name>myWebsocketFilter</filter-name>
        <url-pattern>/websocket/myendpoint</url-pattern>
    </filter-mapping>

</web-app>

the async-supported was suggested by BalusC in my question to support asynchronous message sending.

TL,DR

if you need to manipulate GET parameters provided by the client at the connection time, Filter can be a solution if you are satisfied with a pure HTTP answer (403 status code and so on)

Configurator

As you may have noticed, I have added configuration = MyWebsocketConfiguration.class. Such class looks like:

public class MyWebsocketConfigurationextends ServerEndpointConfig.Configurator {

    // as the name suggests, we operate here at the handshake level
    // so we can start talking in websocket vocabulary
    @Override
    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {

        // much like ServletRequest, the HandshakeRequest contains
        // all the information provided by the client at connection time
        // a common usage is:
        Map<String, List<String>> parameters = request.getParameterMap();

        // this is not a Map<String, String> to handle situation like
        // URL = /websocket/myendpoint?token=value1&token=value2
        // then the key "token" is bound to the list {"value1", "value2"}
        sec.getUserProperties().put("myFetchedToken", parameters.get("token"));
    }
}

Okay, great, how is this different from a filter? The big difference is that you're adding here some information in the user properties during the handshake. That means that the @OnOpen can have access to this information:

@ServerEndpoint(value = "/websocket/myendpoint", 
                configuration = MyWebsocketConfiguration.class)
public class MyEndpoint{

     // you can fetch the information added during the
     // handshake via the EndpointConfig
     @OnOpen
     public void onOpen(Session session, EndpointConfig config){
         List<String> token = (List<String>) config.getUserProperties().get("myFetchedToken");

         // now you can manipulate the token:
         if(token.isEmpty()){
             // for example: 
             session.close(new CloseReasons(CloseReason.CloseCodes.CANNOT_ACCEPT, "the token is mandatory!");
         }
     }

    // @OnClose, @OnMessage, @OnError...
}

TL;DR

You want to manipulate some parameters but process the possible error in a websocket way? Create your own configuration.

Try/catch

I also mentioned the try/catch option:

@ServerEndpoint(value = "/websocket/myendpoint")
public class MyEndpoint{

     @OnOpen
     public void onOpen(Session session, EndpointConfig config){

         // by catching the exception and handling yourself
         // here, the @OnError will never be called. 
         try{
             Long token = HTTP.getRequiredLongParameter(session, "token");
             // process your token
         }
         catch(BadRequestException e){
             // as you suggested:
             session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, "some text"));
         }
     }

    // @OnClose, @OnMessage, @OnError...
}

Hope this help