Introduction to gRPC in Python : A Hands-on Guide to Modern Client-Server Communication

In this blog, we will learn about how to use gRPC in Python with the help of some examples where we will have client-server architecture.

Introduction to gRPC in Python : A Hands-on Guide to Modern Client-Server Communication

Introduction

In this blog, we delve into the world of gRPC, a cutting-edge framework developed by Google for seamless and efficient client-server communication. Designed to work across various programming languages, gRPC is a powerful tool for modern microservices architecture. Through practical examples and detailed explanations, this guide will walk you through how to use gRPC in Python.

What is gRPC?

The official website says, “gRPC is a modern, open-source, high-performance Remote Procedure Call (RPC) framework that can run in any environment”.

The gRPC is a framework by Google used for efficient communication between two services, in request-response architecture. It supports many programming languages like C#, C++, Go, Java, Python, and many more. It is mainly used for communication between different microservices (could be in different programming languages). For example, consider an Order microservice written in Python that needs to process payments by calling the Payment microservice written in Java.

Simply put, gRPC enables applications to call functions across different programming languages and services as if they were local functions - whether they're written in the same language or different ones.

Also, we can’t use gRPC to make communication between the webpage and the server, because all popular browsers (as of 2024) don’t support gRPC. gRPC uses HTTP/2 with TCP.

Video on basics of RPC and gRPC

I would recommend you to watch this video before proceeding. This video by Arpit Bhayani will help you to clear your theory needed for the code that we will discuss.

Prerequisite

  1. Python version 3.7+

  2. pip version 9.0.1+

Now, run these commands in the terminal to install the required libraries :

  1. python -m pip install grpcio

  2. python -m pip install grpcio-tools

Note : If you are not able to install these libraries directly, make a virtual environment for Python and then install them.

Generating Protocol Buffer Header Files

gRPC uses Protocol Buffer (ProtoBuf) which is used to generate a header file (code file) that implements the given service specification in .proto file. The header file is generated based on which programming language will be using that service.

See how we will generate header files for the Order-Payment example,

As we can see we have to compile the payment.proto twice to generate code since we have both microservices written in different programming languages, so we have header files in different languages.

If we have both microservices written in the same language. Then we will only need to compile payment.proto once since both microservices are in the same programming language both can import the same header file.

To compile the .proto file :

python -m grpc_tools.protoc -I. --grpc_python_out=. --python_out= <file-name>.proto

(Here replace <file-name> with the actual filename, in our case payment.proto)

After compiling we get two header files generated in the same folder,

  1. payment_pb2.py

  2. payment_pb2_grpc.py

Making .proto file

payment.proto

syntax = "proto3";

package payment;

service Payment {
  rpc MakePayment (PaymentRequest) returns (PaymentResponse) {}
}

message PaymentRequest {
  double amount = 1;
  int32 card = 2;
}

message PaymentResponse {
  string result = 1;
}

Firstly, we give the same name to the package as of the .proto file, for us it’s payment.

package payment;

We also have a defined service that contains API/functions definition that can be used for request-response between client-server or server-server.

service Payment { ... }

Here, we have one service for making payment, so we define it in service Payment

service Payment {
  rpc MakePayment (PaymentRequest) returns (PaymentResponse) {}
}

Now, after have defined all the API/functions definitions, we will give definitions of input and output types used in various API/functions, for us it’s PaymentRequest and PaymentResponse.

message PaymentRequest {
  double amount = 1;        // here 1 signifies 1st position in type
  int32 card = 2;           // here 2 signifies 2nd position in type
}

message PaymentResponse {
  string result = 1;        // here cannot use 0 (zero), the field number must be a positive value
}

These message acts a struct or class (based on language) after getting compiled.

To sum up what we have to define in the .proto file is

  1. Give syntax type

  2. Give package name

  3. Define the service and in that give definitions of functions/APIs you’ll need in the communications

  4. Define the fields present in each message (struct datatype) and a field number (a positive value)(nonzero)

  5. Compile the .proto file

Understanding a Basic Code of Client and Server Example

As I told you, gRPC is as simple as calling functions/APIs. Now, let’s jump into code

This is straightforward code : the client sends “name” as a string and the server responds with a hello(string) response.

  1. service.proto : here we will compile it and get service_pb2.py and service_pb2_grpc.py
syntax = "proto3";

package service;

service Service {
  rpc Get (Request) returns (Response) {}
}

message Request {
  string name = 1;
}

message Response {
  string message = 1;
}
  1. server.py : Uses thread pool to handle multiple requests at once.
import grpc                            # library for gRPC code
import service_pb2                     # generated header file from .proto
import service_pb2_grpc                # generated header file from .proto
from concurrent import futures         # since server will use threadpool

class Service:                         # implementing Service function(s) (present in .proto file)
    def Get(self, request, context):
        return service_pb2.Response(message = "Hello " + request.name)        # "Response" as output of function

def serve():
    # initializing grpc.server() with threadpool
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))

    # These lines are boilerplate code for server in gRPC code
    service_pb2_grpc.add_ServiceServicer_to_server(Service(), server)
    server.add_insecure_port('[::]:50051')
    server.start()
    server.wait_for_termination()

if __name__ == "__main__":
    serve()
  1. client.py
import grpc                    # library for gRPC code
import service_pb2             # generated header file from .proto
import service_pb2_grpc        # generated header file from .proto

def run():
    channel = grpc.insecure_channel('localhost:50051')
    stub = service_pb2_grpc.ServiceStub(channel)
    request = service_pb2.Request(name = "Sam")
    try:
        response = stub.Get(request)
        print(f"Response : {response.message}")
    except:
        print("Error in getting response")

if __name__ == '__main__':
    run()

Now, understand what is happening in this code,

  1. For Server,

    In this, we implemented the functions/APIs present in .proto file

     class Service:                         # implementing Service function(s) (present in .proto file)
         def Get(self, request, context):
             return service_pb2.Response(message = "Hello " + request.name)        # "Response" as output of function
    

    Then we define the server

     def serve():
         # initializing grpc.server() with threadpool
         server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    
         # These lines are boilerplate code for server in gRPC code
         service_pb2_grpc.add_ServiceServicer_to_server(Service(), server)
         server.add_insecure_port('[::]:50051')
         server.start()
         server.wait_for_termination()
    

    Now understand the code line by line of the server serve function,

    1. We initialize the gRPC server by giving it a thread pool for concurrency.

    2. In this line, the server registers the Service with the Servicer, enabling gRPC clients to access its functionality.

    3. The server starts an insecure channel on port number 50051.

    4. Start the server

  2. For Client,

    Client code is very simple, it just starts communication with the server and just makes a request (execute server function). The client first makes a stub and then uses it to request the server, without a stub it will be very hard for us to make requests to a server directly as they are mostly implemented in different languages.

A Stub is used for the conversion of your Python code request to any desired language in which the Server is implemented, It provides an abstraction to the client for easier usage. In Arpit’s video (mentioned above), he discussed the stub in-depth, please look at that video for a better understanding. You can also look into this blog.

Note : In this example, I am using client-server architecture, but in microservices, it’s like server-server. The reason I am using client-server architecture is that it’s a request-response architecture where the client is making requests and the server is responsible for handling the request and providing the response.

Some More Examples

I generated this code using Google Gemini based on my description [AI Generated Code]

  1. Multiple functions in one service
# Proto file (shapes.proto)
syntax = "proto3";
package shapes;

service Shapes {
  rpc GetArea (RectangleRequest) returns (AreaResponse);
  rpc GetPerimeter (RectangleRequest) returns (PerimeterResponse);
  rpc GetVolume (CuboidRequest) returns (VolumeResponse);
}

message RectangleRequest {
  double length = 1;
  double width = 2;
}
message CuboidRequest {
  double length = 1;
  double width = 2;
  double height = 3;
}
message AreaResponse {
  double area = 1;
}
message PerimeterResponse {
  double perimeter = 1;
}
message VolumeResponse {
  double volume = 1;
}

# server.py
import grpc
import shapes_pb2
import shapes_pb2_grpc
from concurrent import futures
import time

class ShapesService(shapes_pb2_grpc.ShapesServicer):
    def GetArea(self, request, context):
        print(f"Calculating area for: {request}")  # Print for demonstration
        time.sleep(2) # Simulate some work
        area = request.length * request.width
        return shapes_pb2.AreaResponse(area=area)
    def GetPerimeter(self, request, context):
        print(f"Calculating perimeter for: {request}")  # Print for demonstration
        time.sleep(1) # Simulate some work
        perimeter = 2 * (request.length + request.width)
        return shapes_pb2.PerimeterResponse(perimeter=perimeter)
    def GetVolume(self, request, context):
        print(f"Calculating volume for: {request}")  # Print for demonstration
        time.sleep(3) # Simulate some work
        volume = request.length * request.width * request.height
        return shapes_pb2.VolumeResponse(volume=volume)

def serve():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) #Important for parallel execution
    shapes_pb2_grpc.add_ShapesServicer_to_server(ShapesService(), server)
    server.add_insecure_port('[::]:50051')
    server.start()
    print("Server started. Listening on port 50051.")
    server.wait_for_termination()

if __name__ == '__main__':
    serve()

# client.py (using threads)
import grpc
import shapes_pb2
import shapes_pb2_grpc
import threading

def call_get_area(stub):
    response = stub.GetArea(shapes_pb2.RectangleRequest(length=5, width=10))
    print(f"Area: {response.area}")

def call_get_perimeter(stub):
    response = stub.GetPerimeter(shapes_pb2.RectangleRequest(length=3, width=7))
    print(f"Perimeter: {response.perimeter}")

def call_get_volume(stub):
    response = stub.GetVolume(shapes_pb2.CuboidRequest(length=2, width=4, height=6))
    print(f"Volume: {response.volume}")

def run():
    with grpc.insecure_channel('localhost:50051') as channel:
        stub = shapes_pb2_grpc.ShapesStub(channel)

        threads = [
            threading.Thread(target=call_get_area, args=(stub,)),
            threading.Thread(target=call_get_perimeter, args=(stub,)),
            threading.Thread(target=call_get_volume, args=(stub,))
        ]

        for thread in threads:
            thread.start()

        for thread in threads:
            thread.join()

if __name__ == '__main__':
    run()
  1. Using 2 proto files
# shape.proto
syntax = "proto3";
package shapes;

service ShapesService {
  rpc GetArea (RectangleRequest) returns (AreaResponse);
}

message RectangleRequest {
  double length = 1;
  double width = 2;
}
message AreaResponse {
  double area = 1;
}

# calculations.proto
syntax = "proto3";
package calculations;

service CalculationService {
  rpc Add (AddRequest) returns (AddResponse);
}

message AddRequest {
  double num1 = 1;
  double num2 = 2;
}
message AddResponse {
  double result = 1;
}

# server.py
import grpc
import shapes_pb2
import shapes_pb2_grpc
import calculations_pb2
import calculations_pb2_grpc
from concurrent import futures

class ShapesServicer(shapes_pb2_grpc.ShapesServiceServicer):
    def GetArea(self, request, context):
        area = request.length * request.width
        return shapes_pb2.AreaResponse(area=area)

class CalculationServicer(calculations_pb2_grpc.CalculationServiceServicer):
    def Add(self, request, context):
        result = request.num1 + request.num2
        return calculations_pb2.AddResponse(result=result)

def serve():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))

    # Add both servicers to the server
    shapes_pb2_grpc.add_ShapesServiceServicer_to_server(ShapesServicer(), server)
    calculations_pb2_grpc.add_CalculationServiceServicer_to_server(CalculationServicer(), server)

    server.add_insecure_port('[::]:50051')
    server.start()
    print("Server started. Listening on port 50051.")
    server.wait_for_termination()

if __name__ == '__main__':
    serve()

# Client code is not provided since, it's easy to implement
  1. Server running multiple threads apart from the service based on gRPC
import grpc
import shapes_pb2
import shapes_pb2_grpc
from concurrent import futures
import threading
import time

# ... (ShapesServicer implementation as before) ...

def serve():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    shapes_pb2_grpc.add_ShapesServiceServicer_to_server(ShapesServicer(), server)
    server.add_insecure_port('[::]:50051')
    server.start()
    print("gRPC Server started in separate thread.")
    server.wait_for_termination() # This will block the thread it is running on

def other_task():
    while True:
        print("Doing some other work...")
        time.sleep(1)

if __name__ == '__main__':
    server_thread = threading.Thread(target=serve)
    server_thread.daemon = True # Allow the main thread to exit even if this thread is running
    server_thread.start()

    other_task_thread = threading.Thread(target=other_task)
    other_task_thread.start()

    try:
        while True:
            time.sleep(0.1)  # Keep the main thread alive
    except KeyboardInterrupt:
        print("Stopping...")
        #server.stop(0)   # If you want to stop the server gracefully
        #server_thread.join() # If server_thread was not daemon
        exit(0) # or sys.exit(0)
  1. Client using timeout for gRPC requests (written by me)
# service.proto
syntax = "proto3";
package service;

service Service {
  rpc Get (Request) returns (Response) {}
}

message Request {
  string name = 1;
}
message Response {
  string message = 1;
}

# server.py
import grpc
import service_pb2
import service_pb2_grpc
from concurrent import futures
import time

class Service:
    def Get(self, request, context):
        # time.sleep(0.3)             # use it to check how timeout happens
        return service_pb2.Response(message = "Hello " + request.name)

def serve():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    service_pb2_grpc.add_ServiceServicer_to_server(Service(), server)
    server.add_insecure_port('[::]:50051')
    server.start()
    server.wait_for_termination()

if __name__ == "__main__":
    serve()

# client.py
import grpc
import service_pb2
import service_pb2_grpc

def run():
    channel = grpc.insecure_channel('localhost:50051')
    stub = service_pb2_grpc.ServiceStub(channel)
    request = service_pb2.Request(name = "Sam")
    try:
        response = stub.Get(request,timeout=0.1)
        print(f"Response : {response.message}")
    except:
        print("Error in getting response")

if __name__ == '__main__':
    run()

Conclusion

gRPC is a very simple and powerful framework used for inter-server communication and is used by Netflix microservices for efficient communication.

This blog is just an introduction to gRPC with some good examples in Python on how to use gRPC. To study more about gRPC you can read blogs and docs from the official website. Also, you can ask your doubts from Google Gemini but sometimes it may generate buggy code, so just I use it for learning/exploring purposes.

Thank you for reading till the end! 🙌 I hope you found this blog enjoyable and interesting. You can also read my other blogs on my profile.

You can contact me on Twitter(𝕏).

Did you find this article valuable?

Support Shreyansh Thoughts by becoming a sponsor. Any amount is appreciated!