4.3. Calling and handling Services from Python

4.3.1. What can LN Services provide?

This quickstart example shows how to define and call LN services from Python. Services are different from LN messages. While the latter are fire-and-forgot messages, services work much like a function call: Running code calls another piece of code with some parameters, and as a result, it gets one or more return values back. While this happens, the current flow of execution is paused, so the programmer does not need to handle the result concurrently, resulting in an easy control flow.

The differences to normal function calls are three-fold:

  1. the code providing the computation can run in another process

  2. the code can also be written in another language

  3. the code can also run on another computer, as long as a network connection exists.

To achieve these things, links and nodes uses, again, messages, which are however defined slightly different. They have slightly different message definitions, called service message definitions. Therefore, we will start out again with these.

4.3.2. Our example: Geometric heavy lifting

Let’s assume we have a process which needs to make some really complicated geometry calculations. For the purpose of this example, let’s just show how to compute areas of a circle, a triangle, or an ellipse, so that we can keep the sample code short. (In practice, it can of course be any kind of computation, or object-oriented method call.)

4.3.3. Defining message definitions for service calls

To call services for this computation, we first need to create message definitions with the parameters and return values of each computation.

As mentioned, this message definition is a bit different from the former examples. Specifically, it has two distinguished sections, one under the label request, and one under the name response. As you might guess, the message parts under “request” are the input or call parameters of the computation, and the message parts under “response” are the return parameters.

Create a sub-folder msg_defs/area_service/, and create three message definitions like that:

  1. the file circle_area:

service
request
double radius

response
uint32_t error_code
char* error_message
uint32_t error_message_len

double area

To compute the area of an circle, one only needs to know its radius as an input parameter. The resulting area is the return parameter. Both parameters are of type double, which is a standard 64-bit floating point number (as it is used in C/C++).

Because errors can happen almost everywhere in code, we add two fields which are needed for error handling: an error_code field, and an error_message field, which is basically an array of bytes with a variable length. The length is stored into error_message_len (when using the Python API, this happens automatically).

We create a second file named triangle_area:

service
request
double base_length
double height

response
uint32_t error_code
char* error_message
uint32_t error_message_len

double area

In the case of an triangle, we need to know the base width and the height, for example.

For the third case, we create a file ellipse_area:

service
request
double a
double b

response
uint32_t error_code
char* error_message
uint32_t error_message_len

double area

Here, a and b mean the smaller and the larger semi-axis of the ellipse, which is a popular notation (if we wanted, we could of course also use longer variable names, to support our memory about the meaning).

4.3.4. Defining a service provider in Python

Now, we need to define code which actually does the computation. As before, we write it in Python, and give it the name ln_quickstart_services/provider.py.

4.3.4.1. Initialization

We start with the initialization of the provider class. Here is how it looks:

 1#!/usr/bin/env python3
 2
 3import links_and_nodes as ln
 4import traceback
 5import math
 6from math import pi
 7
 8from area_api_constants import ErrorCodes as ec
 9
10
11class area_provider(object):
12    def __init__(self):
13        self.clnt = ln.client("test_provider")
14
15        self.circle_provider = self.clnt.get_service_provider(
16            "area_service.compute_circle_area", "area_service/circle_area"
17        )
18        self.circle_provider.set_handler(self.compute_circle_area)
19        self.circle_provider.do_register("default group")
20
21        self.triangle_provider = self.clnt.get_service_provider(
22            "area_service.compute_triangle_area", "area_service/triangle_area"
23        )
24        self.triangle_provider.set_handler(self.compute_triangle_area)
25        self.triangle_provider.do_register("default group")
26
27        self.ellipse_provider = self.clnt.get_service_provider(
28            "area_service.compute_ellipse_area", "area_service/ellipse_area"
29        )
30        self.ellipse_provider.set_handler(self.compute_ellipse_area)
31        self.ellipse_provider.do_register("default group")
32

This is the code which initializes the service provider class and its handlers for service message requests. To explain it very briefly:

  • We first define a class which provides handler methods, so-called service handlers.

  • in line 13, it initializes an links_and_nodes.client object, which is used to manage all the communication.

  • in lines 15, 21, and 27, it initializes three LN service provider handles, using the method links_and_nodes.client.get_service_provider(). The two parameters of the method calls are each time first the name of the service call that is provided, and the name of a service message definition. Both names need to match between service provider and service client.

    In our case, the message definition names are the message definitions which we defined above, and the names passed do not include the folder in which the message definition is stored.

  • then, in lines 18, 24 and 30, a handler method is stored for each service call that the class wants to respond, using the method links_and_nodes.service_provider.set_handler(). That handler method is a Python function, in our case it is simply a method of the class area_provider that we will define shortly.

  • as the final part of the initialization, the method links_and_nodes.service_provider.do_register() actually registers the service provider with the LN manager, so that service name, message definition name, and the handler function that implements it are logically joined.

    The single parameter of links_and_nodes.service_provider.do_register() is the name of the service group, which is here the default service group, so all call handlers will run in the main thread of our process.

Tip

Note that while in many cases, names of services and message definitions will be almost identical, they can be different. Specifically, as message definitions define simple data types, these can also originate from a system library or another package which provides a unique definition. (For further reading, the section on Message Definitions and Topics in the tutorial gives a more in-depth explanation of the relationships between message definitions, topics, and their names.)

4.3.4.2. Handler Methods

Now, how do the service handlers look like? They are defined this way:

 33    def compute_circle_area(self, conn, req, resp):
 34        if req.radius < 0:
 35            resp.area = math.nan
 36            resp.error_message = "compute_circle_area(): negative radius input value"
 37            resp.error_code = ec.NEGATIVE_INPUT
 38            print(
 39                "compute_circle_area(): we return an error and NaN as value for a negative radius"
 40            )
 41
 42        else:
 43            if req.radius == 0:
 44                resp.error_message = "compute_circle_area(): zero in radius input value"
 45                resp.error_code = ec.ZERO_INPUT
 46            else:
 47                resp.error_code = ec.NO_ERROR
 48
 49            area_val = pi * req.radius**2
 50            print(
 51                "compute_circle_area(): circle with radius %5.2f has area %5.2f\n"
 52                % (req.radius, area_val)
 53            )
 54            resp.area = area_val
 55
 56        conn.respond()
 57        return 0
 58
 59    def compute_triangle_area(self, conn, req, resp):
 60        if req.base_length < 0 or req.height < 0:
 61            resp.area = math.nan
 62            resp.error_message = (
 63                "compute_triangle_area(): negative side length input value"
 64            )
 65            resp.error_code = ec.NEGATIVE_INPUT
 66            print(
 67                "compute_triangle_area(): we return an error and NaN as"
 68                " value for a negative side length"
 69            )
 70
 71        else:
 72            if req.base_length == 0 or req.height == 0:
 73                resp.error_message = (
 74                    "compute_triangle_area(): zero in side length input value"
 75                )
 76                resp.error_code = ec.ZERO_INPUT
 77            else:
 78                resp.error_code = ec.NO_ERROR
 79
 80            area_val = (req.base_length * req.height) / 2.0
 81            print(
 82                "compute_triangle_area(): triangle with base length = %5.2f "
 83                "and height = %5.2f has area  %f\n"
 84                % (req.base_length, req.height, area_val)
 85            )
 86            resp.area = area_val
 87
 88        conn.respond()
 89        return 0
 90
 91    class EllipseException(Exception):
 92        def __init__(self, code, message, optret=None):
 93            self.error_code = code
 94            self.error_message = message
 95            self.optret = optret
 96            Exception.__init__(self)
 97
 98        def __str__(self):
 99            return "EllipseException: %r (code %r)" % (
100                self.error_message,
101                self.error_code,
102            )
103
104    def lib_compute_ellipse_area(self, a, b):
105        # this simulates a function from an external library
106        if a < 0 or b < 0:
107            raise self.EllipseException(
108                ec.NEGATIVE_INPUT,
109                "compute_ellipse_area(): negative axis length input value",
110                math.nan,
111            )
112
113        if a == 0 or b == 0:
114            raise self.EllipseException(
115                ec.ZERO_INPUT, "compute_ellipse_area(): zero in axis length input value"
116            )
117
118        area_val = pi * a * b
119
120        print(
121            "compute_ellipse_area(): ellipse with semi-major axis a %5.2f "
122            "and semi-minor axis b %5.2f has area  %5.2f\n" % (a, b, area_val)
123        )
124        return area_val
125
126    def compute_ellipse_area(self, conn, req, resp):
127        try:
128            resp.area = self.lib_compute_ellipse_area(req.a, req.b)
129            resp.error_code = ec.NO_ERROR
130            resp.error_message = ""  # todo: report as bug against LN api
131
132        except self.EllipseException as e:
133            print("compute_ellipse_area(): %s" % str(e))
134            resp.error_code = e.error_code
135            resp.error_message = e.error_message
136            if e.optret is not None:
137                resp.area = e.optret
138
139        except:
140            resp.error_code = ec.OTHER_ERROR
141            resp.error_message = traceback.format_exc()
142            print("compute_ellipse_area(): unknown exception:\n%s" % resp.error_message)
143
144        conn.respond()
145        return 0
146

Each handler method has three parameters conn, req, and resp. The first is an object that represents the service call connection. The connection is essentially an object which offers several methods that can handle (respond, abort, and so on) a service call.

The second parameter, named req, is an object that represents the call parameters that are transmitted with the service call - each member of req corresponds to one member or element of the “request” part of the message definition.

The third parameter, called resp, represents the response data structure or response buffer. Its members correspond to the “response” section of the associated service message definition. Before responding the call, the results are assigned to the members of that structure.

In the three methods of our example, called compute_circle_area(), compute_triangle_area(), and compute_ellipse_area(), the different formulas for each geometric figure are evaluated. We see that in each method, in the first part, the input parameters are checked for sanity: For example, in lines 34 to 40 of compute_circle_area(), the radius of the circle is checked whether it is smaller than zero; if this is the case, the fields resp.error_message and resp.error_code are set, so that the caller of the service method can know what went wrong. Note that even in this branch of the code path, the respond() method is called, since the error result needs to be communicated back to the client.

If, in turn, all parameters are correct, the computed value is assigned to resp.area. When all response fields are assigned, the method links_and_nodes.connection.respond() is called, which returns the response data to the caller, and finishes the request.

In the handler method compute_ellipse_area(), we see another case that we need to cover: Here, the computation of the area of an ellipse is done by calling a library function, which could throw an exception (in our example, we emulate an external library function by calling into the function lib_compute_ellipse_area(), but it could be of course any function defined in some external library).

In that case, we need to catch any exception that could occur, as is done in the try.... except clause in lines 127 to 137, so that the error is caught and a sensible error message is returned to the client. Otherwise, the client might receive an error message that could not be comprehensible at all, which is generated by the LN service handler code when it catches the exception on the run.

4.3.4.3. Running the Handlers

As the final parts of the service provider script, we need one more method definition, and a function call that runs the provider. This is done like shown here:

147    def run(self):
148        print("ready to receive service calls")
149        while True:
150            self.clnt.wait_and_handle_service_group_requests("default group", 0.5)
151
152
153if __name__ == "__main__":
154    p = area_provider()
155    p.run()

The function which actually performs the handling of service requests has the name links_and_nodes.client.wait_and_handle_service_group_requests(). Here, the first parameter is the group of service handlers which will be run to serve a request. We set it to the same service group name as we have passed in the calls to do_register() above.

The second parameter is the maximum waiting time which the call will wait for a request to arrive. In our example, the service handlers will run in the main thread of the program, which keeps things nicely simple.

Avoid polling the same service group from multiple application threads. LN serializes concurrent calls internally, but one dispatcher thread per service group is the recommended structure. Use a service group thread pool if handlers should run concurrently.

Note

It is also possible to run service handlers in multiple threads. However, this requires thread safe programming, which becomes quickly very difficult and has a high risk of difficult-to-debug errors like race conditions. For technical reasons, pure Python code is also unlikely to run faster in several threads. Therefore, it is strongly recommended to use simpler means unless processing in multiple threads is absolutely required. [1]

4.3.5. Calling services from Python

4.3.5.1. Instantiating the Client

As the counterpart, we need, of course, code which calls the computation. For this, we create a service client program ln_quickstart_services/client.py like this:

 1#!/usr/bin/env python3
 2
 3import random
 4import links_and_nodes as ln
 5
 6
 7class area_service_client(object):
 8    def __init__(self):
 9        self.clnt = ln.client("area_service_client")
10
11        self.compute_circle_svc = self.clnt.get_service(
12            "area_service.compute_circle_area", "area_service/circle_area"
13        )
14        self.compute_triangle_svc = self.clnt.get_service(
15            "area_service.compute_triangle_area", "area_service/triangle_area"
16        )
17        self.compute_ellipse_svc = self.clnt.get_service(
18            "area_service.compute_ellipse_area", "area_service/ellipse_area"
19        )
20
21    def compute_circle_area(self, radius):
22        svc = self.compute_circle_svc
23        svc.req.radius = radius
24        svc.call()
25        if svc.resp.error_code != 0:
26            print(
27                "Error: code = {}, message = {}".format(
28                    svc.resp.error_code, svc.resp.error_message
29                )
30            )
31        return svc.resp.area
32
33    def compute_triangle_area(self, base_length, height):
34        svc = self.compute_triangle_svc
35        svc.req.base_length = base_length
36        svc.req.height = height
37        svc.call()
38        if svc.resp.error_code != 0:
39            print(
40                "Error: code = {}, message = {}".format(
41                    svc.resp.error_code, svc.resp.error_message
42                )
43            )
44        return svc.resp.area
45
46    def compute_ellipse_area(self, a, b):
47        svc = self.compute_ellipse_svc
48        svc.req.a = a
49        svc.req.b = b
50        svc.call()
51        if svc.resp.error_code != 0:
52            print(
53                "Error: code = {}, message = {}".format(
54                    svc.resp.error_code, svc.resp.error_message
55                )
56            )
57        return svc.resp.area
58
59
60if __name__ == "__main__":
61    c = area_service_client()
62
63    for n in range(10):
64        a = random.uniform(-2, 10)
65        A = c.compute_circle_area(a)
66
67        print("circle result: %5.2f" % A)
68
69    for n in range(10):
70        a = random.uniform(-2, 10)
71        b = random.uniform(0, 10)
72        A = c.compute_triangle_area(a, b)
73
74        print("triangle result: %5.2f" % A)
75
76    for n in range(10):
77        a = random.uniform(-2, 10)
78        b = random.uniform(0, 10)
79        A = c.compute_ellipse_area(a, b)
80
81        print("ellipse result: %5.2f" % A)

Here, the method call links_and_nodes.client.get_service() has again two parameters, the first is, exactly as before, the name of the service method that is provided (which here is equal to the the function name at the provider side), and the second is the used service message definition. The links_and_nodes.client.get_service() method returns again a service handle of type links_and_nodes.service for the client.

Attention

This retrieval of the handle should only be done once during the initialization of the client program, because it can take much longer than the ongoing actual calls!

4.3.5.2. Calling the Service from Client Methods

Now, we can write methods which call the service. As an example, we can look at the definition of compute_circle_area(): It has the needed parameter radius.

For performing the call, it first creates a shortcut name to the service client handle of type links_and_nodes.service, which we call svc. After that, it can assign the request parameters to the member fields of links_and_nodes.service.req. In our case, this is svc.req.radius. Then, the service is actually called by calling links_and_nodes.service.call(), which sends the data to the service provider, waits for the result of the computation, and transports it back - including any possibly generated error messages.

The response data is in the member fields of svc.resp, which is of type links_and_nodes.service.resp. To check for the error, we just look whether the value svc.resp.error_code is larger than zero; if this is the case, we can use svc.resp.error_message in order to print a diagnostic message, for example. (In many cases, idiomatic Python code would raise a suitable exception here that signals a failure - doing this is however within the responsibility of the service client).