Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 290 additions & 0 deletions docs/advanced/async-processing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
Async Processing
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 appreciate writing an awesome documentation.

However, the docs have been migrated here: https://github.com/twirphp/twirphp.github.io

Could you send a PR there? It's in markdown format though.

I'm happy to merge this PR without the docs PR though, so it's not a blocker.

================

This document describes how to use asynchronous processing with Twirp PHP clients using Guzzle's promise framework.

Overview
--------

All generated Twirp PHP clients now support asynchronous request processing using promises that conform to the PSR-7 standard and leverage Guzzle's promise implementation. This allows you to make non-blocking HTTP requests and handle multiple requests concurrently.

Requirements
------------

* **Guzzle HTTP Client** (^7.0): For async support, you must use Guzzle as your HTTP client
* **guzzlehttp/promises** (^1.5 or ^2.0): Promise implementation (usually installed as a Guzzle dependency)

Installation
------------

If not already installed, add Guzzle to your project:

.. code-block:: bash

composer require guzzlehttp/guzzle

Basic Usage
-----------

Synchronous Request (Existing Behavior)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.. code-block:: php

use GuzzleHttp\Client as GuzzleClient;
use YourNamespace\GreeterClient;
use YourNamespace\HelloRequest;

// Create a Guzzle HTTP client
$httpClient = new GuzzleClient();

// Create the Twirp client
$client = new GreeterClient('http://localhost:8080', $httpClient);

// Make a synchronous request
$request = new HelloRequest();
$request->setName('World');

try {
$response = $client->SayHello([], $request);
echo $response->getMessage();
} catch (\Twirp\Error $e) {
echo "Error: " . $e->getMessage();
}

Asynchronous Request (New Feature)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.. code-block:: php

use GuzzleHttp\Client as GuzzleClient;
use YourNamespace\GreeterClient;
use YourNamespace\HelloRequest;

// Create a Guzzle HTTP client
$httpClient = new GuzzleClient();

// Create the Twirp client
$client = new GreeterClient('http://localhost:8080', $httpClient);

// Make an asynchronous request
$request = new HelloRequest();
$request->setName('World');

// Returns a promise immediately
$promise = $client->SayHelloAsync([], $request);

// Handle the response when it's ready
$promise->then(
function ($response) {
echo "Success: " . $response->getMessage();
},
function ($exception) {
echo "Error: " . $exception->getMessage();
}
);

// Wait for the promise to resolve (optional)
$promise->wait();

Advanced Usage
--------------

Multiple Concurrent Requests
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

You can make multiple asynchronous requests and wait for all of them to complete:

.. code-block:: php

use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Promise;
use YourNamespace\GreeterClient;
use YourNamespace\HelloRequest;

$httpClient = new GuzzleClient();
$client = new GreeterClient('http://localhost:8080', $httpClient);

// Create multiple requests
$promises = [];

foreach (['Alice', 'Bob', 'Charlie'] as $name) {
$request = new HelloRequest();
$request->setName($name);
$promises[$name] = $client->SayHelloAsync([], $request);
}

// Wait for all promises to complete
$results = Promise\Utils::settle($promises)->wait();

// Process results
foreach ($results as $name => $result) {
if ($result['state'] === 'fulfilled') {
echo "$name: " . $result['value']->getMessage() . "\n";
} else {
echo "$name failed: " . $result['reason']->getMessage() . "\n";
}
}

Using Promise::all()
^^^^^^^^^^^^^^^^^^^^

If you want all requests to succeed or fail together:

.. code-block:: php

use GuzzleHttp\Promise;

$promises = [
'alice' => $client->SayHelloAsync([], $request1),
'bob' => $client->SayHelloAsync([], $request2),
'charlie' => $client->SayHelloAsync([], $request3),
];

// Wait for all promises - throws if any fails
try {
$results = Promise\Utils::all($promises)->wait();
foreach ($results as $name => $response) {
echo "$name: " . $response->getMessage() . "\n";
}
} catch (\Exception $e) {
echo "One or more requests failed: " . $e->getMessage();
}

Chaining Promises
^^^^^^^^^^^^^^^^^

You can chain multiple operations:

.. code-block:: php

$client->SayHelloAsync([], $request)
->then(function ($response) use ($client) {
// Use the response to make another request
$followUpRequest = new AnotherRequest();
$followUpRequest->setData($response->getMessage());
return $client->AnotherMethodAsync([], $followUpRequest);
})
->then(function ($finalResponse) {
echo "Final result: " . $finalResponse->getResult();
})
->otherwise(function ($exception) {
echo "Error in chain: " . $exception->getMessage();
})
->wait();

Non-blocking Execution
^^^^^^^^^^^^^^^^^^^^^^

For truly non-blocking execution, don't call ``wait()``:

.. code-block:: php

// Fire off async requests without waiting
$promise1 = $client->Method1Async([], $request1);
$promise2 = $client->Method2Async([], $request2);

// Do other work here
doOtherWork();

// Later, check if promises are resolved
if ($promise1->getState() === 'fulfilled') {
$result = $promise1->wait(); // Returns immediately since already resolved
}

Fallback Behavior
-----------------

If you don't use Guzzle as your HTTP client (e.g., using a generic PSR-18 client), the async methods will automatically fall back to synchronous execution and return a resolved promise. This ensures backward compatibility.

.. code-block:: php

use Symfony\Component\HttpClient\Psr18Client;

// Non-Guzzle client
$httpClient = new Psr18Client();
$client = new GreeterClient('http://localhost:8080', $httpClient);

// This will execute synchronously but still return a promise
$promise = $client->SayHelloAsync([], $request);
$response = $promise->wait();

Error Handling
--------------

Async methods throw the same ``\Twirp\Error`` exceptions as synchronous methods, but they're caught in the promise rejection handler:

.. code-block:: php

$client->SayHelloAsync([], $request)
->then(
function ($response) {
// Success handler
return $response;
},
function ($error) {
// Error handler
if ($error instanceof \Twirp\Error) {
echo "Twirp Error Code: " . $error->getErrorCode() . "\n";
echo "Message: " . $error->getMessage() . "\n";

// Check metadata
$metadata = $error->getMeta();
if (isset($metadata['http_error_from_intermediary'])) {
echo "HTTP intermediary error\n";
}
}
throw $error; // Re-throw if you want to propagate
}
);

Best Practices
--------------

1. **Use Guzzle for async operations**: While other PSR-18 clients work, only Guzzle supports true async processing.

2. **Handle errors appropriately**: Always provide rejection handlers for your promises to catch errors.

3. **Don't call wait() in loops**: If making many requests, collect all promises first, then use ``Promise\Utils::settle()`` or ``Promise\Utils::all()``.

4. **Consider connection pooling**: Guzzle reuses connections by default, which is more efficient for multiple requests.

5. **Set appropriate timeouts**: Configure Guzzle with appropriate timeouts for async operations:

.. code-block:: php

$httpClient = new GuzzleClient([
'timeout' => 10.0,
'connect_timeout' => 5.0,
]);

JSON Client Support
-------------------

Both the Protobuf client and JSON client support async operations:

.. code-block:: php

use YourNamespace\GreeterJsonClient;

$jsonClient = new GreeterJsonClient('http://localhost:8080', $httpClient);
$promise = $jsonClient->SayHelloAsync([], $request);

Performance Considerations
--------------------------

Async requests provide the most benefit when:

* Making multiple independent requests that can run concurrently
* Dealing with high-latency services
* Building services that need to remain responsive while waiting for I/O

For single requests with no other work to do, synchronous requests may be simpler and sufficient.

More Information
----------------

* `Guzzle Promises Documentation <https://github.com/guzzle/promises>`_
* `Guzzle Async Requests <https://docs.guzzlephp.org/en/stable/quickstart.html#async-requests>`_
* `PSR-7: HTTP Message Interface <https://www.php-fig.org/psr/psr-7/>`_

1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ resources published by the Twirp developers themselves:
advanced/other-services
advanced/http-headers
advanced/psr15
advanced/async-processing


.. _Twirp: https://twitchtv.github.io/twirp/
Expand Down
48 changes: 48 additions & 0 deletions protoc-gen-twirp_php/templates/service/AbstractClient.php.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ declare(strict_types=1);
namespace {{ .File | phpNamespace }};

use Google\Protobuf\Internal\Message;
use GuzzleHttp\Promise\PromiseInterface;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Discovery\Psr18ClientDiscovery;
use Psr\Http\Client\ClientInterface;
Expand Down Expand Up @@ -100,12 +101,49 @@ abstract class {{ .Service | phpServiceName .File }}AbstractClient

return $out;
}

/**
* Async version of {{ $method.Desc.Name }} that returns a promise.
*
* @param array $ctx Context array
* @param {{ $inputType }} $in Request message
* @return PromiseInterface A promise that resolves to {{ $method.Output | phpMessageName $.File }}
*/
public function {{ $method.Desc.Name }}Async(array $ctx, {{ $inputType }} $in): PromiseInterface
{
$ctx = Context::withPackageName($ctx, '{{ $.File.Proto.GetPackage }}');
$ctx = Context::withServiceName($ctx, '{{ $.Service.Desc.Name }}');
$ctx = Context::withMethodName($ctx, '{{ $method.Desc.Name }}');

$out = new {{ $method.Output | phpMessageName $.File }}();

$url = $this->addr;
if (empty($this->prefix)) {
$url = $url.'/{{ $method | protoMethodFullName }}';
} else {
$url = $url.'/'.$this->prefix.'/{{ $method | protoMethodFullName }}';
}

return $this->doRequestAsync($ctx, $url, $in, $out);
}
{{ end }}
/**
* Common code to make a request to the remote twirp service.
*/
abstract protected function doRequest(array $ctx, string $url, Message $in, Message $out): void;

/**
* Common code to make an async request to the remote twirp service.
* Returns a promise that resolves to void when the $out message is populated.
*
* @param array $ctx Context array
* @param string $url The URL to send the request to
* @param Message $in The input message to serialize and send
* @param Message $out The output message to populate with the response
* @return PromiseInterface A promise that resolves to the output message
*/
abstract protected function doRequestAsync(array $ctx, string $url, Message $in, Message $out): PromiseInterface;

/**
* Makes an HTTP request and adds common headers.
*/
Expand Down Expand Up @@ -190,6 +228,16 @@ abstract class {{ .Service | phpServiceName .File }}AbstractClient
return $error;
}

/**
* Helper method to check if the HTTP client supports async operations (Guzzle).
*
* @return bool
*/
protected function supportsAsync(): bool
{
return method_exists($this->httpClient, 'sendAsync');
}

/**
* Maps HTTP errors from non-twirp sources to twirp errors.
* The mapping is similar to gRPC: https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md.
Expand Down
Loading
Loading