1212use OpenTelemetry \SDK \Common \Future \ErrorFuture ;
1313use OpenTelemetry \SDK \Common \Future \FutureInterface ;
1414use RuntimeException ;
15+ use Swoole \Coroutine ;
16+ use Swoole \Coroutine \Channel ;
1517use Swoole \Coroutine \Http2 \Client ;
1618use Swoole \Http2 \Request ;
1719use Throwable ;
@@ -22,6 +24,8 @@ final class SwooleGrpcTransport implements TransportInterface
2224
2325 private ?Client $ client = null ;
2426
27+ private ?Channel $ mutex = null ;
28+
2529 /**
2630 * @SuppressWarnings(PHPMD.BooleanArgumentFlag)
2731 */
@@ -33,6 +37,8 @@ public function __construct(
3337 private readonly float $ timeout = 10.0 ,
3438 private readonly bool $ ssl = false ,
3539 private readonly ?string $ compression = null ,
40+ private readonly int $ retryDelay = 100 ,
41+ private readonly int $ maxRetries = 3 ,
3642 ) {
3743 }
3844
@@ -47,42 +53,21 @@ public function send(string $payload, ?CancellationInterface $cancellation = nul
4753 return new ErrorFuture (new RuntimeException ('Transport is closed ' ));
4854 }
4955
50- try {
51- $ client = $ this ->getClient ();
52-
53- $ data = $ this ->compress ($ payload );
54-
55- $ request = new Request ();
56- $ request ->method = 'POST ' ;
57- $ request ->path = $ this ->method ;
58- $ request ->headers = $ this ->buildHeaders ();
59- $ request ->data = $ this ->packMessage ($ data );
60-
61- $ streamId = $ client ->send ($ request );
62-
63- if ($ streamId === false || $ streamId <= 0 ) {
64- return new ErrorFuture (new RuntimeException (
65- 'Failed to send gRPC request: ' . ($ client ->errMsg ?: 'unknown error ' )
66- ));
67- }
68-
69- $ response = $ client ->recv ($ this ->timeout );
70-
71- if ($ response === false ) {
72- return new ErrorFuture (new RuntimeException (
73- 'Failed to receive gRPC response: ' . ($ client ->errMsg ?: 'timeout ' )
74- ));
75- }
56+ $ mutex = $ this ->getMutex ();
57+ if ($ mutex ->pop () === false ) {
58+ return new ErrorFuture (new RuntimeException ('Transport is closed ' ));
59+ }
7660
77- $ grpcStatus = $ response ->headers ['grpc-status ' ] ?? '0 ' ;
78- if ($ grpcStatus !== '0 ' ) {
79- $ grpcMessage = $ response ->headers ['grpc-message ' ] ?? 'Unknown error ' ;
80- return new ErrorFuture (new RuntimeException ("gRPC error: {$ grpcMessage }" , (int ) $ grpcStatus ));
61+ try {
62+ if ($ this ->closed ) {
63+ return new ErrorFuture (new RuntimeException ('Transport is closed ' ));
8164 }
8265
83- return new CompletedFuture ( null );
66+ return $ this -> attemptSend ( $ payload );
8467 } catch (Throwable $ e ) {
8568 return new ErrorFuture ($ e );
69+ } finally {
70+ $ mutex ->push (true ); // release — returns false silently if channel was closed by shutdown
8671 }
8772 }
8873
@@ -93,6 +78,7 @@ public function shutdown(?CancellationInterface $cancellation = null): bool
9378 }
9479
9580 $ this ->closed = true ;
81+ $ this ->mutex ?->close(); // unblock any coroutines waiting to acquire the mutex
9682
9783 if ($ this ->client !== null ) {
9884 $ this ->client ->close ();
@@ -107,19 +93,126 @@ public function forceFlush(?CancellationInterface $cancellation = null): bool
10793 return ! $ this ->closed ;
10894 }
10995
96+ /**
97+ * @SuppressWarnings(PHPMD.ErrorControlOperator)
98+ */
99+ private function attemptSend (string $ payload ): FutureInterface
100+ {
101+ $ data = $ this ->compress ($ payload );
102+ $ lastError = null ;
103+
104+ for ($ attempt = 0 ; $ attempt <= $ this ->maxRetries ; ++$ attempt ) {
105+ if ($ this ->closed ) {
106+ return new ErrorFuture (new RuntimeException ('Transport is closed ' ));
107+ }
108+
109+ if ($ attempt > 0 && $ this ->retryDelay > 0 ) {
110+ Coroutine::sleep ($ this ->retryDelay / 1000.0 );
111+ }
112+
113+ try {
114+ return $ this ->doRequest ($ this ->getClient (), $ data );
115+ } catch (Throwable $ e ) {
116+ $ this ->resetClient ();
117+ $ lastError = $ e ;
118+ }
119+ }
120+
121+ return new ErrorFuture ($ lastError ?? new RuntimeException ('Unknown transport error after retries ' ));
122+ }
123+
124+ /**
125+ * Executes a single gRPC send+recv cycle.
126+ *
127+ * Throws RuntimeException on send failure so the caller can reset the client and retry.
128+ * Returns ErrorFuture on recv failure (delivery uncertain — no retry).
129+ *
130+ * Note: @$client->recv() suppresses E_DEPRECATED from Swoole setting $serverLastStreamId
131+ * as a dynamic property in PHP 8.2+. Hyperf's ErrorExceptionHandler would otherwise convert
132+ * the deprecation notice into an ErrorException, aborting the recv() call.
133+ *
134+ * @throws RuntimeException on retryable send failure
135+ * @SuppressWarnings(PHPMD.ErrorControlOperator)
136+ */
137+ private function doRequest (Client $ client , string $ data ): FutureInterface
138+ {
139+ $ request = new Request ();
140+ $ request ->method = 'POST ' ;
141+ $ request ->path = $ this ->method ;
142+ $ request ->headers = $ this ->buildHeaders ();
143+ $ request ->data = $ this ->packMessage ($ data );
144+
145+ $ streamId = $ client ->send ($ request );
146+
147+ if ($ streamId === false || $ streamId <= 0 ) {
148+ // Throw so the retry loop can reset the client and try again;
149+ // data was never transmitted so retrying is safe.
150+ throw new RuntimeException (
151+ 'Failed to send gRPC request: ' . ($ client ->errMsg ?: 'unknown error ' )
152+ );
153+ }
154+
155+ $ response = @$ client ->recv ($ this ->timeout );
156+
157+ if ($ response === false ) {
158+ $ this ->resetClient ($ client );
159+ // Do not retry: send succeeded, so delivery is uncertain — retrying could duplicate exports.
160+ return new ErrorFuture (new RuntimeException (
161+ 'Failed to receive gRPC response: ' . ($ client ->errMsg ?: 'timeout ' )
162+ ));
163+ }
164+
165+ $ grpcStatus = (int ) ($ response ->headers ['grpc-status ' ] ?? 0 );
166+ if ($ grpcStatus !== 0 ) {
167+ $ grpcMessage = $ response ->headers ['grpc-message ' ] ?? 'Unknown error ' ;
168+ // gRPC application error — not retried
169+ return new ErrorFuture (new RuntimeException ("gRPC error: {$ grpcMessage }" , $ grpcStatus ));
170+ }
171+
172+ return new CompletedFuture (null );
173+ }
174+
175+ private function getMutex (): Channel
176+ {
177+ if ($ this ->mutex === null ) {
178+ $ this ->mutex = new Channel (1 );
179+ $ this ->mutex ->push (true ); // initially unlocked
180+ }
181+
182+ return $ this ->mutex ;
183+ }
184+
185+ private function resetClient (?Client $ client = null ): void
186+ {
187+ $ target = $ client ?? $ this ->client ;
188+ $ target ?->close();
189+ if ($ target === $ this ->client ) {
190+ $ this ->client = null ;
191+ }
192+ }
193+
110194 private function getClient (): Client
111195 {
112- if ($ this ->client === null || ! $ this ->client ->connected ) {
113- $ this ->client = new Client ($ this ->host , $ this ->port , $ this ->ssl );
114- $ this ->client ->set ([
196+ if ($ this ->client !== null && ! $ this ->client ->connected ) {
197+ $ this ->client ->close ();
198+ $ this ->client = null ;
199+ }
200+
201+ if ($ this ->client === null ) {
202+ $ client = new Client ($ this ->host , $ this ->port , $ this ->ssl );
203+ $ client ->set ([
115204 'timeout ' => $ this ->timeout ,
116205 ]);
117206
118- if (! $ this ->client ->connect ()) {
207+ if (! $ client ->connect ()) {
208+ $ errMsg = $ client ->errMsg ;
209+ $ client ->close ();
119210 throw new RuntimeException (
120- "Failed to connect to {$ this ->host }: {$ this ->port }: " . $ this -> client -> errMsg
211+ "Failed to connect to {$ this ->host }: {$ this ->port }: " . $ errMsg
121212 );
122213 }
214+
215+ $ this ->client = $ client ;
123216 }
124217
125218 return $ this ->client ;
@@ -165,6 +258,7 @@ private function buildHeaders(): array
165258 private function packMessage (string $ data ): string
166259 {
167260 $ compressed = $ this ->compression !== null ? 1 : 0 ;
261+ // gRPC message frame: [1-byte compressed flag][4-byte big-endian message length][message]
168262 return pack ('CN ' , $ compressed , strlen ($ data )) . $ data ;
169263 }
170264}
0 commit comments