Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redesign communication client types #409

Merged

Conversation

robertbartel
Copy link
Contributor

@robertbartel robertbartel commented Aug 7, 2023

  • Separation of transport layer client duties to separate type(s), and moving websocket client to this hierarchy
  • Separation of auth to separate types
  • Moving to more composition and less strict type hinting in service clients

@robertbartel robertbartel added enhancement New feature or request maas MaaS Workstream labels Aug 7, 2023
@robertbartel robertbartel requested a review from aaraney August 7, 2023 17:12
@robertbartel
Copy link
Contributor Author

This is still very much a work in progress, and I haven't even committed all the changes I've been making (just those specifically in client.py so far). I wanted to get this available to look at though, to show the general direction(s) I am headed in.

Redesigning and renaming abstraction, making it more focused on just the
transport layer communication handling, rather than general service
client behavior; also extending new type with a subtype that adds SSL
context security.

Making RequestClient concrete but generic.

Add ConnectionContextClient as an abstract transport client than
maintains a connection via an async managed context, and
(re)implementing WebSocketClient using the new (secure) transport
client with a context managed connection.

Update specialized clients to extend RequestClient

Fix ExternalClient with changes.
@robertbartel robertbartel force-pushed the f/cli_updates/client_refactor branch from 15271be to 3ef01cf Compare August 9, 2023 16:42
@robertbartel robertbartel marked this pull request as ready for review August 10, 2023 19:11
@robertbartel
Copy link
Contributor Author

The remaining failing tests are in the dmod.client package. That also will have some significant changes coming soon, beyond just what is needed to remedy the failures. Unless there are any objections, I am going to keep those changes separate and give them their own PR.

Copy link
Member

@aaraney aaraney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general I think this is a great step in an improved direction! Thanks for reworking this @robertbartel. I left several comments that will require a bit of conversation, but once we've worked through that, we should be on the road. Thanks!

@@ -115,135 +104,277 @@ async def async_recv(self) -> str:
"""
pass


class SSLSecuredTransportLayerClient(TransportLayerClient, ABC):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts on removing this and injecting an optional ssl_context in TransportLayerClient's initializer?

Something like:

  def __init__(self, endpoint_uri: str, *args, ssl_context: Optional[ssl.SSLContext] = None, **kwargs) ): ...

We could then just provide a helper function to create an ssl context from a directory. So pretty much what client_ssl_context does. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After thinking a little about it, I'd prefer not to accept an SSLContext directly. I'm splitting hairs a bit, but this strictly speaking would increase the ways we could end up with an improperly/incompletely initialized context in the client. If we did that, I would want us to somehow ensure that the _SSLMethod was right and that the context had right the CA certs loaded. I think its easier to just be responsible for creating the context object.

We could accept an optional SSL directory as part of the TransportLayerClient's initializer and create the context there instead of the lazy property. Then we could still remove SSLSecuredTransportLayerClient. What are your thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that is a fair tradeoff, introducing a little complexity to avoid misuse. My only request would be to provide a way to pass a default ssl context.

Copy link
Contributor Author

@robertbartel robertbartel Aug 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated things to optionally bundle the SSL context into TransportLayerClient, remove SSLSecuredTransportLayerClient, and parameterize whether to use a default context.

python/lib/communication/dmod/communication/_version.py Outdated Show resolved Hide resolved
python/lib/communication/dmod/communication/client.py Outdated Show resolved Hide resolved
python/lib/communication/dmod/communication/client.py Outdated Show resolved Hide resolved
msg = f'********** {self.__class__.__name__} could not deserialize {response_type.__name__} ' \
f'from raw websocket response: `{str(response_str)}`'
reason = f'{self.__class__.__name__} Could Not Deserialize To {response_type.__name__}'
response_object = response_type(success=False, reason=reason, message=msg, data=response_json)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really wish that we had algebraic datatypes in python, I think that concept would be really useful here. My main concern is returning client side errors as if they are response objects from the server. Meaning, there is no semantic distinction between a server sent failed response and a response the client couldnt handle.

Like you have it written, I dont think we should throw here, but I do think we should either return a tuple of an optional response and an optional client side error or return either a response or a client side error type. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not entirely certain we shouldn’t throw here. It is fundamentally an exception to what the client can handle. To properly address it that way, we’d need a (or a few) custom exception type(s) for indicating the specific problem(s). And then we'd need to handle the exception properly somewhere else further up the stack.

I'm not definitively sold on that as a solution, though. My first impression is that it'd be a little less clean, but I'd consider a solution that involved returning a tuple.

Regardless, addressing this properly would be fairly intricate, and what's in place now was carried over from previous code, albeit perhaps arranged slightly differently. I.e., it's out of scope for this PR, but it deserves its own issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And then we'd need to handle the exception properly somewhere else further up the stack.

Right, this is my primary motivation for trying to treat the exceptions as values instead of throwing here. Treating them as values without attaching for example, a traceback, could also become difficult to debug. I personally think it is worth the headache though. Exceptions are so easy to ignore / mishandle.

Regardless, addressing this properly would be fairly intricate, and what's in place now was carried over from previous code, albeit perhaps arranged slightly differently. I.e., it's out of scope for this PR, but it deserves its own issue.

Also totally fair, thanks for calling out of the scope creep!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, I've opened #418 for this.

response_json = {}
try:
# Send the request and get the service response
serialized_response = await self._transport_client.async_send(data=str(message), await_response=True)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering is not guaranteed here so you could get a response message for a different request or different request type all together. At the moment, I dont think that is the case given how we are using websockets currently, but this is certainly something that we will need to prioritize solving soon.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. I'm still considering whether to attempt to address that here or in a subsequent PR, and how to accomplish that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for letting me know where you are with this. JLMK if you plan to address it in a separate PR, for now I will treat it like you are going to address it here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am addressing this is a separate PR: #417.

python/lib/communication/dmod/communication/client.py Outdated Show resolved Hide resolved
Making this a static method rather than a class method, as it doesn't
need any class details.
Moving to top-level interface rather than having subtype for this.
Removing redundant SSLSecuredTransportLayerClient subtype.
Removing redundant SSLSecuredTransportLayerClient from communication
package __init__.py.
Modifying TransportLayerClient to expect init params for (most of) the
components of a URI, rather than the endpoint URI directly, and adding
abstract property so that concrete types can implement this themselves
(e.g., to control the specific protocol component used in the URI).
Copy link
Member

@aaraney aaraney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working through this, @robertbartel! I left a few comments that mainly address some usability issues. After we work through that, this should be good to go. Thanks!

"""
pass

def __init__(self, endpoint_host: str, endpoint_port: Union[int, str], endpoint_path: Optional[str] = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like it is doing a lot, but I think it is alright. Thanks for reworking this @robertbartel! My only comment is, thoughts on making all arguments required keyword arguments? So the signature would change to:

      def __init__(
        self,
        *,
        endpoint_host: str,
        endpoint_port: Union[int, str],
        endpoint_path: Optional[str] = None,
        cafile: Optional[Path] = None,
        capath: Optional[Path] = None,
        use_default_context: bool = False,
        **kwargs
    ):

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I've just made and pushed that change.

"""
pass

@property
def client_ssl_context(self) -> Optional[ssl.SSLContext]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be public?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, no. I went ahead and changed that as well.

python/lib/communication/dmod/communication/client.py Outdated Show resolved Hide resolved
@@ -117,133 +161,266 @@ async def async_recv(self) -> str:

@property
@abstractmethod
def client_ssl_context(self) -> ssl.SSLContext:
def endpoint_uri(self) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be public? Just trying to reduce the public interface to only what is required.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll change this also for now, but it (or something close to it) may have to come back for #417. My reasoning there may have actually been circular, though, so maybe it is superfluous.

@property
def is_new_session(self):
return self._is_new_session
def force_reload(self) -> bool:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it does not need to be reimplemented in the subclass. Am I missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean. This is being introduced by this class (CachedAuthClient). There is no previous declaration/definition.

return '{}://{}:{}{}'.format(proto, host.strip(), str(port).strip(), path)

def __init__(self, ssl_directory: Path, *args, **kwargs):
def __init__(self, transport_client: TransportLayerClient, default_response_type: Optional[Type[Response]] = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think its a good idea to make these keyword only too. Its probably best that we do that with all of these classes given that we have a fair amount of inheritance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I've made that change too.

Cleaning up TransportLayerClient __init__ to not pass var/keyword args
to super (ABC), and removing abstract annotation from implemented
method in WebSocketClient.
Fixing setup of scheduler client unit tests by adding implementation of
this classmethod, declared as abstract in upstream superclass.
robertbartel and others added 6 commits September 8, 2023 15:55
Removing public property in TransportLayerClient and replacing with use
of private attribute.
Clarify that endpoint_host does not include protocol in docstring.

Co-authored-by: Austin Raney <austin.raney@noaa.gov>
Copy link
Member

@aaraney aaraney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for making the changes, @robertbartel! Merge at will!

@robertbartel robertbartel merged commit 9f964e1 into NOAA-OWP:master Sep 12, 2023
@robertbartel robertbartel deleted the f/cli_updates/client_refactor branch September 13, 2023 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request maas MaaS Workstream
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants