How to properly report an error to client through WebSockets
Asked Answered
L

2

7

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.

Lexy answered 2/9, 2017 at 2:10 Comment(7)
Is it possible that throwing an exception in OnOpen is closing the session before OnError is called? Are you seeing "Throwable in closed websocket" being logged?Tonnage
@Remy Nope, "throwable in closed..." is not logged.Lexy
Edited with new info. It is functionally acceptable as-is. But what I'm really seeking is the "best practice" answer that is helpful to my server's clients, and may be nothing like what I'm doing.Lexy
Have you considered putting a filter ahead? Unrelated question but why your class is annotated with ServerEndpoint and ClientEndpoint at the same time? If you don't want to use a filter, you can also consider a custom configuration where you'll check if the parameter is here or not and what value it hasDrugi
@ASE I know little about filters. I think I have a good answer, though (see answer). ClientEndpoint was combined with ServerEndpoint in the first example I found. Thanks for pointing out; removed now.Lexy
@Remy I now believe you had the answer. It was obscured because OnError wasn't even entered. If you make an Answer, I'll pick it.Lexy
@Lexy I was kind of lazy to find relevant link so I posted an answer which can give you some food for thought. It may not directly help for this problem but it definitively helped me a lot for my websocket designDrugi
L
1

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.)

Lexy answered 9/9, 2017 at 18:35 Comment(1)
the onError is triggered on exception. As Remy Lebeau suggested, if an exception is thrown anywhere, in your particular case during @OnOpen, then @OnError is called. If you wish to close the connection during @OnOpen without having an exception thrown, I guess your best bet is to put a try/catch and close the connection in your catchDrugi
D
2

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

Drugi answered 9/9, 2017 at 23:20 Comment(1)
I don't really know the etiquette of SO but I guess you should rather append your edit to your post. Btw, your edit contains very important information :)Drugi
L
1

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.)

Lexy answered 9/9, 2017 at 18:35 Comment(1)
the onError is triggered on exception. As Remy Lebeau suggested, if an exception is thrown anywhere, in your particular case during @OnOpen, then @OnError is called. If you wish to close the connection during @OnOpen without having an exception thrown, I guess your best bet is to put a try/catch and close the connection in your catchDrugi

© 2022 - 2024 — McMap. All rights reserved.