Skip to content

Migrate HTTP layer to utopia-php/client - #147

Closed
deepshekhardas wants to merge 1 commit into
utopia-php:mainfrom
deepshekhardas:fix/pr-134-migrate-client
Closed

Migrate HTTP layer to utopia-php/client#147
deepshekhardas wants to merge 1 commit into
utopia-php:mainfrom
deepshekhardas:fix/pr-134-migrate-client

Conversation

@deepshekhardas

Copy link
Copy Markdown

Rebased on latest main.

Replace the hand-rolled cURL in Adapter::request()/requestMulti() with the
utopia-php/client PSR-18 client, and have every adapter consume the PSR-7
response directly (getStatusCode/getBody/getHeaderLine) instead of the old
custom result array.

- request() builds a PSR-7 request via the request factory (json/form/
  multipart chosen from Content-Type) and returns ResponseInterface.
- requestMulti() sends sequentially over one HTTP/2, connection-reused
  client (APNs requires HTTP/2) and returns responses in request order;
  callers map results by position.
- Mailgun attachments now use Psr7 multipart Part::file instead of
  curl_file_create; fixed a latent Vonage associative-header bug.
- Test stubs (SESStub/ResendStub) return Utopia\Psr7\Response.

utopia-php/client requires PHP 8.4, so bump composer (require + platform),
the Dockerfile to php:8.4, and phpstan to ^2 (needed to parse the client's
8.4 syntax). Also fix a PHP 8.4 implicit-nullable deprecation in JWT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Thanks for contributing! This repository is a read-only mirror; development for this library happens in packages/messaging in the utopia-php monorepo. Please open this pull request there instead.

@github-actions github-actions Bot closed this Jul 25, 2026
@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown

Greptile Summary

The PR replaces the messaging HTTP result arrays with PSR-7 responses and moves request construction and transport to utopia-php/client.

  • Migrates provider adapters to PSR-7 status, body, and header access.
  • Reworks batch push requests around a reusable HTTP/2 client.
  • Updates Composer dependencies and adds Resend and SES routing tests.

Confidence Score: 1/5

This PR is unsafe to merge because the shared Adapter and several providers are no longer loadable PHP, while batch transport failures also skip remaining recipients.

The malformed opening of the base Adapter makes the core library unavailable through Composer autoloading, and the new requestMulti loop converts an isolated recipient transport failure into an aborted batch while serializing all recipient requests.

Files Needing Attention: src/Utopia/Messaging/Adapter.php and the affected one-line PHP adapter and test files

Important Files Changed

Filename Overview
src/Utopia/Messaging/Adapter.php Replaces the shared HTTP implementation, but its malformed PHP opening prevents the base class from loading and its sequential batch loop aborts after a transport failure.
src/Utopia/Messaging/Adapter/Push/APNS.php Migrates APNS to PSR-7 responses, but the file has the same invalid opening and depends on the non-isolating batch path.
src/Utopia/Messaging/Adapter/Push/FCM.php Migrates FCM token and delivery requests to PSR-7 responses, but the file cannot load and batch failures skip remaining recipients.
src/Utopia/Messaging/Adapter/Email/Mailgun.php Migrates Mailgun request and response handling, but the malformed PHP opening prevents the adapter from loading.
src/Utopia/Messaging/Adapter/Email/SES.php Reworks SES routing around the new HTTP layer, but the malformed PHP opening prevents the adapter from loading.
composer.json Revises runtime and development dependencies and lowers the declared PHP platform to 8.4.

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/Utopia/Messaging/Adapter.php:1
**Malformed PHP opening tags**

When Composer autoloads this base class, `<?phpnamespace` is not recognized as a valid PHP opening-tag boundary, so the class is not declared and the messaging library cannot initialize. The same malformed prefix affects Mailgun, SES, APNS, FCM, Inforu, Twilio, Vonage, JWT, and both new routing tests.

### Issue 2 of 3
src/Utopia/Messaging/Adapter.php:1
**Batch failures abort remaining sends**

When an APNS or FCM request raises a transport exception, the direct `sendRequest()` call exits the loop without attempting later recipients, causing one recipient failure to abort the rest of the batch instead of producing an isolated per-recipient result.

### Issue 3 of 3
src/Utopia/Messaging/Adapter.php:1
**Batch requests are now sequential**

`requestMulti()` now waits for every `sendRequest()` before starting the next one, removing the previous bounded concurrency and contradicting the documented concurrent HTTP/2 batch behavior. Large APNS and FCM sends therefore accumulate one request's latency per recipient.

Reviews (1): Last reviewed commit: "Migrate HTTP layer to utopia-php/client" | Re-trigger Greptile

];
}
}
<?phpnamespace Utopia\Messaging;use Exception;use libphonenumber\PhoneNumberUtil;use Psr\Http\Message\RequestInterface;use Psr\Http\Message\ResponseInterface;use Utopia\Client;use Utopia\Client\Adapter\Curl\Client as CurlAdapter;use Utopia\Psr7\Header;use Utopia\Psr7\Request\Factory as RequestFactory;use Utopia\Telemetry\Adapter as Telemetry;use Utopia\Telemetry\Adapter\None as NoTelemetry;use Utopia\Telemetry\Counter;abstract class Adapter{ private Counter $sendCounter; public function __construct(?Telemetry $telemetry = null) { $this->sendCounter = ($telemetry ?? new NoTelemetry())->createCounter('messaging.send'); } /** * Get the name of the adapter. */ abstract public function getName(): string; /** * Get the type of the adapter. */ abstract public function getType(): string; /** * Get the type of the message the adapter can send. */ abstract public function getMessageType(): string; /** * Get the maximum number of messages that can be sent in a single request. */ abstract public function getMaxMessagesPerRequest(): int; /** * Send a message. * * @return array{ * deliveredTo: int, * type: string, * results: array<array<string, mixed>> * } | array<string, array{ * deliveredTo: int, * type: string, * results: array<array<string, mixed>> * }> GEOSMS adapter returns an array of results keyed by adapter name. * * @throws \Exception */ public function send(Message $message): array { if (!\is_a($message, $this->getMessageType())) { throw new \Exception('Invalid message type.'); } if (\method_exists($message, 'getTo') && \count($message->getTo()) > $this->getMaxMessagesPerRequest()) { throw new \Exception("{$this->getName()} can only send {$this->getMaxMessagesPerRequest()} messages per request."); } if (!\method_exists($this, 'process')) { throw new \Exception('Adapter does not implement process method.'); } try { $response = $this->process($message); } catch (\Throwable $error) { $this->recordSend($message, \method_exists($message, 'getTo') ? \count($message->getTo()) : 1, 0); throw $error; } $this->recordResponse($message, $response); return $response; } public function setTelemetry(Telemetry $telemetry): void { $this->sendCounter = $telemetry->createCounter('messaging.send'); } private function recordSend(Message $message, int $recipients, int $delivered): void { if ($delivered > 0) { $this->sendCounter->add($delivered, $this->telemetryAttributes($message, [ 'result' => 'success', ])); } $failed = $recipients - $delivered; if ($failed > 0) { $this->sendCounter->add($failed, $this->telemetryAttributes($message, [ 'result' => 'failure', ])); } } /** * @param array<string, mixed> $attributes * @return array<string, mixed> */ private function telemetryAttributes(Message $message, array $attributes = []): array { if ($message->getOrigin() !== null) { $attributes['origin'] = $message->getOrigin(); } return $attributes + [ 'type' => $this->getType(), 'provider' => \strtolower($this->getName()), ]; } /** * @param array<string, mixed> $response */ private function recordResponse(Message $message, array $response): void { $results = $response['results'] ?? []; if (empty($results)) { return; } $delivered = 0; $failed = 0; foreach ($results as $result) { ($result['status'] ?? '') === 'success' ? $delivered++ : $failed++; } $this->recordSend($message, $delivered + $failed, $delivered); } /** * Send a single HTTP request and return the client's PSR-7 response. * * @param string $method The HTTP method to use. * @param string $url The URL to send the request to. * @param array<string> $headers Headers as "Key: value" strings. * @param array<string, mixed>|null $body The body of the request. * @param int $timeout The timeout in seconds. * * @throws \Psr\Http\Client\ClientExceptionInterface If the request fails at the transport level. */ protected function request( string $method, string $url, array $headers = [], ?array $body = null, int $timeout = 30, int $connectTimeout = 10 ): ResponseInterface { return $this->client($timeout, $connectTimeout) ->sendRequest($this->buildRequest($method, $url, $headers, $body)); } /** * Send multiple HTTP requests over a single kept-alive HTTP/2 connection. * Responses are returned in request order, so the Nth response corresponds * to the Nth recipient. * * @param array<string> $urls * @param array<string> $headers Headers as "Key: value" strings. * @param array<array<string, mixed>> $bodies * @return array<ResponseInterface> * * @throws Exception */ protected function requestMulti( string $method, array $urls, array $headers = [], array $bodies = [], int $timeout = 30, int $connectTimeout = 10 ): array { if (empty($urls)) { throw new \Exception('No URLs provided. Must provide at least one URL.'); } $urlCount = \count($urls); $bodyCount = \count($bodies); if (!($urlCount == $bodyCount || $urlCount == 1 || $bodyCount == 1)) { throw new \Exception('URL and body counts must be equal or one must equal 1.'); } if ($urlCount > $bodyCount) { $bodies = \array_pad($bodies, $urlCount, $bodies[0]); } elseif ($urlCount < $bodyCount) { $urls = \array_pad($urls, $bodyCount, $urls[0]); } $client = $this->client($timeout, $connectTimeout, multi: true); $responses = []; foreach ($urls as $i => $url) { $responses[] = $client->sendRequest($this->buildRequest($method, $url, $headers, $bodies[$i])); } return $responses; } /** * Build a client carrying the adapter's user agent and timeouts. When * $multi is set the cURL transport negotiates HTTP/2 and keeps the * connection alive so a batch of requests to the same host reuses it. */ private function client(int $timeout, int $connectTimeout, bool $multi = false): Client { $adapter = new CurlAdapter( options: $multi ? [\CURLOPT_HTTP_VERSION => \CURL_HTTP_VERSION_2_0] : [], ); return (new Client($adapter)) ->withTimeout((float) $timeout) ->withConnectTimeout((float) $connectTimeout) ->withConnectionReuse($multi) ->withHeaders([Header::USER_AGENT => "Appwrite {$this->getName()} Message Sender"]); } /** * Translate the legacy "Key: value" header list and body array into a * PSR-7 request, picking the body encoding from the Content-Type header. * * @param array<string> $headers * @param array<string, mixed>|null $body */ private function buildRequest(string $method, string $url, array $headers, ?array $body): RequestInterface { $factory = new RequestFactory(); $contentType = ''; $map = []; foreach ($headers as $header) { [$key, $value] = \array_pad(\explode(':', $header, 2), 2, ''); $key = \trim($key); $value = \trim($value); if (\strtolower($key) === 'content-type') { $contentType = \strtolower($value); continue; } $map[$key] = $value; } $body ??= []; return match (true) { \str_contains($contentType, 'application/x-www-form-urlencoded') => $factory->form($method, $url, $body, $map), \str_contains($contentType, 'multipart/form-data') => $factory->multipart($method, $url, $body, $map), default => $factory->json($method, $url, $body, $map), }; } /** * @param string $phone * @return int|null * @throws Exception */ public function getCountryCode(string $phone): ?int { if (empty($phone)) { throw new Exception('$phone cannot be empty.'); } $helper = PhoneNumberUtil::getInstance(); try { return $helper ->parse($phone) ->getCountryCode(); } catch (\Throwable $th) { throw new Exception("Error parsing phone: " . $th->getMessage()); } }} No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 Malformed PHP opening tags

When Composer autoloads this base class, <?phpnamespace is not recognized as a valid PHP opening-tag boundary, so the class is not declared and the messaging library cannot initialize. The same malformed prefix affects Mailgun, SES, APNS, FCM, Inforu, Twilio, Vonage, JWT, and both new routing tests.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Utopia/Messaging/Adapter.php
Line: 1

Comment:
**Malformed PHP opening tags**

When Composer autoloads this base class, `<?phpnamespace` is not recognized as a valid PHP opening-tag boundary, so the class is not declared and the messaging library cannot initialize. The same malformed prefix affects Mailgun, SES, APNS, FCM, Inforu, Twilio, Vonage, JWT, and both new routing tests.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

];
}
}
<?phpnamespace Utopia\Messaging;use Exception;use libphonenumber\PhoneNumberUtil;use Psr\Http\Message\RequestInterface;use Psr\Http\Message\ResponseInterface;use Utopia\Client;use Utopia\Client\Adapter\Curl\Client as CurlAdapter;use Utopia\Psr7\Header;use Utopia\Psr7\Request\Factory as RequestFactory;use Utopia\Telemetry\Adapter as Telemetry;use Utopia\Telemetry\Adapter\None as NoTelemetry;use Utopia\Telemetry\Counter;abstract class Adapter{ private Counter $sendCounter; public function __construct(?Telemetry $telemetry = null) { $this->sendCounter = ($telemetry ?? new NoTelemetry())->createCounter('messaging.send'); } /** * Get the name of the adapter. */ abstract public function getName(): string; /** * Get the type of the adapter. */ abstract public function getType(): string; /** * Get the type of the message the adapter can send. */ abstract public function getMessageType(): string; /** * Get the maximum number of messages that can be sent in a single request. */ abstract public function getMaxMessagesPerRequest(): int; /** * Send a message. * * @return array{ * deliveredTo: int, * type: string, * results: array<array<string, mixed>> * } | array<string, array{ * deliveredTo: int, * type: string, * results: array<array<string, mixed>> * }> GEOSMS adapter returns an array of results keyed by adapter name. * * @throws \Exception */ public function send(Message $message): array { if (!\is_a($message, $this->getMessageType())) { throw new \Exception('Invalid message type.'); } if (\method_exists($message, 'getTo') && \count($message->getTo()) > $this->getMaxMessagesPerRequest()) { throw new \Exception("{$this->getName()} can only send {$this->getMaxMessagesPerRequest()} messages per request."); } if (!\method_exists($this, 'process')) { throw new \Exception('Adapter does not implement process method.'); } try { $response = $this->process($message); } catch (\Throwable $error) { $this->recordSend($message, \method_exists($message, 'getTo') ? \count($message->getTo()) : 1, 0); throw $error; } $this->recordResponse($message, $response); return $response; } public function setTelemetry(Telemetry $telemetry): void { $this->sendCounter = $telemetry->createCounter('messaging.send'); } private function recordSend(Message $message, int $recipients, int $delivered): void { if ($delivered > 0) { $this->sendCounter->add($delivered, $this->telemetryAttributes($message, [ 'result' => 'success', ])); } $failed = $recipients - $delivered; if ($failed > 0) { $this->sendCounter->add($failed, $this->telemetryAttributes($message, [ 'result' => 'failure', ])); } } /** * @param array<string, mixed> $attributes * @return array<string, mixed> */ private function telemetryAttributes(Message $message, array $attributes = []): array { if ($message->getOrigin() !== null) { $attributes['origin'] = $message->getOrigin(); } return $attributes + [ 'type' => $this->getType(), 'provider' => \strtolower($this->getName()), ]; } /** * @param array<string, mixed> $response */ private function recordResponse(Message $message, array $response): void { $results = $response['results'] ?? []; if (empty($results)) { return; } $delivered = 0; $failed = 0; foreach ($results as $result) { ($result['status'] ?? '') === 'success' ? $delivered++ : $failed++; } $this->recordSend($message, $delivered + $failed, $delivered); } /** * Send a single HTTP request and return the client's PSR-7 response. * * @param string $method The HTTP method to use. * @param string $url The URL to send the request to. * @param array<string> $headers Headers as "Key: value" strings. * @param array<string, mixed>|null $body The body of the request. * @param int $timeout The timeout in seconds. * * @throws \Psr\Http\Client\ClientExceptionInterface If the request fails at the transport level. */ protected function request( string $method, string $url, array $headers = [], ?array $body = null, int $timeout = 30, int $connectTimeout = 10 ): ResponseInterface { return $this->client($timeout, $connectTimeout) ->sendRequest($this->buildRequest($method, $url, $headers, $body)); } /** * Send multiple HTTP requests over a single kept-alive HTTP/2 connection. * Responses are returned in request order, so the Nth response corresponds * to the Nth recipient. * * @param array<string> $urls * @param array<string> $headers Headers as "Key: value" strings. * @param array<array<string, mixed>> $bodies * @return array<ResponseInterface> * * @throws Exception */ protected function requestMulti( string $method, array $urls, array $headers = [], array $bodies = [], int $timeout = 30, int $connectTimeout = 10 ): array { if (empty($urls)) { throw new \Exception('No URLs provided. Must provide at least one URL.'); } $urlCount = \count($urls); $bodyCount = \count($bodies); if (!($urlCount == $bodyCount || $urlCount == 1 || $bodyCount == 1)) { throw new \Exception('URL and body counts must be equal or one must equal 1.'); } if ($urlCount > $bodyCount) { $bodies = \array_pad($bodies, $urlCount, $bodies[0]); } elseif ($urlCount < $bodyCount) { $urls = \array_pad($urls, $bodyCount, $urls[0]); } $client = $this->client($timeout, $connectTimeout, multi: true); $responses = []; foreach ($urls as $i => $url) { $responses[] = $client->sendRequest($this->buildRequest($method, $url, $headers, $bodies[$i])); } return $responses; } /** * Build a client carrying the adapter's user agent and timeouts. When * $multi is set the cURL transport negotiates HTTP/2 and keeps the * connection alive so a batch of requests to the same host reuses it. */ private function client(int $timeout, int $connectTimeout, bool $multi = false): Client { $adapter = new CurlAdapter( options: $multi ? [\CURLOPT_HTTP_VERSION => \CURL_HTTP_VERSION_2_0] : [], ); return (new Client($adapter)) ->withTimeout((float) $timeout) ->withConnectTimeout((float) $connectTimeout) ->withConnectionReuse($multi) ->withHeaders([Header::USER_AGENT => "Appwrite {$this->getName()} Message Sender"]); } /** * Translate the legacy "Key: value" header list and body array into a * PSR-7 request, picking the body encoding from the Content-Type header. * * @param array<string> $headers * @param array<string, mixed>|null $body */ private function buildRequest(string $method, string $url, array $headers, ?array $body): RequestInterface { $factory = new RequestFactory(); $contentType = ''; $map = []; foreach ($headers as $header) { [$key, $value] = \array_pad(\explode(':', $header, 2), 2, ''); $key = \trim($key); $value = \trim($value); if (\strtolower($key) === 'content-type') { $contentType = \strtolower($value); continue; } $map[$key] = $value; } $body ??= []; return match (true) { \str_contains($contentType, 'application/x-www-form-urlencoded') => $factory->form($method, $url, $body, $map), \str_contains($contentType, 'multipart/form-data') => $factory->multipart($method, $url, $body, $map), default => $factory->json($method, $url, $body, $map), }; } /** * @param string $phone * @return int|null * @throws Exception */ public function getCountryCode(string $phone): ?int { if (empty($phone)) { throw new Exception('$phone cannot be empty.'); } $helper = PhoneNumberUtil::getInstance(); try { return $helper ->parse($phone) ->getCountryCode(); } catch (\Throwable $th) { throw new Exception("Error parsing phone: " . $th->getMessage()); } }} No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Batch failures abort remaining sends

When an APNS or FCM request raises a transport exception, the direct sendRequest() call exits the loop without attempting later recipients, causing one recipient failure to abort the rest of the batch instead of producing an isolated per-recipient result.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Utopia/Messaging/Adapter.php
Line: 1

Comment:
**Batch failures abort remaining sends**

When an APNS or FCM request raises a transport exception, the direct `sendRequest()` call exits the loop without attempting later recipients, causing one recipient failure to abort the rest of the batch instead of producing an isolated per-recipient result.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

];
}
}
<?phpnamespace Utopia\Messaging;use Exception;use libphonenumber\PhoneNumberUtil;use Psr\Http\Message\RequestInterface;use Psr\Http\Message\ResponseInterface;use Utopia\Client;use Utopia\Client\Adapter\Curl\Client as CurlAdapter;use Utopia\Psr7\Header;use Utopia\Psr7\Request\Factory as RequestFactory;use Utopia\Telemetry\Adapter as Telemetry;use Utopia\Telemetry\Adapter\None as NoTelemetry;use Utopia\Telemetry\Counter;abstract class Adapter{ private Counter $sendCounter; public function __construct(?Telemetry $telemetry = null) { $this->sendCounter = ($telemetry ?? new NoTelemetry())->createCounter('messaging.send'); } /** * Get the name of the adapter. */ abstract public function getName(): string; /** * Get the type of the adapter. */ abstract public function getType(): string; /** * Get the type of the message the adapter can send. */ abstract public function getMessageType(): string; /** * Get the maximum number of messages that can be sent in a single request. */ abstract public function getMaxMessagesPerRequest(): int; /** * Send a message. * * @return array{ * deliveredTo: int, * type: string, * results: array<array<string, mixed>> * } | array<string, array{ * deliveredTo: int, * type: string, * results: array<array<string, mixed>> * }> GEOSMS adapter returns an array of results keyed by adapter name. * * @throws \Exception */ public function send(Message $message): array { if (!\is_a($message, $this->getMessageType())) { throw new \Exception('Invalid message type.'); } if (\method_exists($message, 'getTo') && \count($message->getTo()) > $this->getMaxMessagesPerRequest()) { throw new \Exception("{$this->getName()} can only send {$this->getMaxMessagesPerRequest()} messages per request."); } if (!\method_exists($this, 'process')) { throw new \Exception('Adapter does not implement process method.'); } try { $response = $this->process($message); } catch (\Throwable $error) { $this->recordSend($message, \method_exists($message, 'getTo') ? \count($message->getTo()) : 1, 0); throw $error; } $this->recordResponse($message, $response); return $response; } public function setTelemetry(Telemetry $telemetry): void { $this->sendCounter = $telemetry->createCounter('messaging.send'); } private function recordSend(Message $message, int $recipients, int $delivered): void { if ($delivered > 0) { $this->sendCounter->add($delivered, $this->telemetryAttributes($message, [ 'result' => 'success', ])); } $failed = $recipients - $delivered; if ($failed > 0) { $this->sendCounter->add($failed, $this->telemetryAttributes($message, [ 'result' => 'failure', ])); } } /** * @param array<string, mixed> $attributes * @return array<string, mixed> */ private function telemetryAttributes(Message $message, array $attributes = []): array { if ($message->getOrigin() !== null) { $attributes['origin'] = $message->getOrigin(); } return $attributes + [ 'type' => $this->getType(), 'provider' => \strtolower($this->getName()), ]; } /** * @param array<string, mixed> $response */ private function recordResponse(Message $message, array $response): void { $results = $response['results'] ?? []; if (empty($results)) { return; } $delivered = 0; $failed = 0; foreach ($results as $result) { ($result['status'] ?? '') === 'success' ? $delivered++ : $failed++; } $this->recordSend($message, $delivered + $failed, $delivered); } /** * Send a single HTTP request and return the client's PSR-7 response. * * @param string $method The HTTP method to use. * @param string $url The URL to send the request to. * @param array<string> $headers Headers as "Key: value" strings. * @param array<string, mixed>|null $body The body of the request. * @param int $timeout The timeout in seconds. * * @throws \Psr\Http\Client\ClientExceptionInterface If the request fails at the transport level. */ protected function request( string $method, string $url, array $headers = [], ?array $body = null, int $timeout = 30, int $connectTimeout = 10 ): ResponseInterface { return $this->client($timeout, $connectTimeout) ->sendRequest($this->buildRequest($method, $url, $headers, $body)); } /** * Send multiple HTTP requests over a single kept-alive HTTP/2 connection. * Responses are returned in request order, so the Nth response corresponds * to the Nth recipient. * * @param array<string> $urls * @param array<string> $headers Headers as "Key: value" strings. * @param array<array<string, mixed>> $bodies * @return array<ResponseInterface> * * @throws Exception */ protected function requestMulti( string $method, array $urls, array $headers = [], array $bodies = [], int $timeout = 30, int $connectTimeout = 10 ): array { if (empty($urls)) { throw new \Exception('No URLs provided. Must provide at least one URL.'); } $urlCount = \count($urls); $bodyCount = \count($bodies); if (!($urlCount == $bodyCount || $urlCount == 1 || $bodyCount == 1)) { throw new \Exception('URL and body counts must be equal or one must equal 1.'); } if ($urlCount > $bodyCount) { $bodies = \array_pad($bodies, $urlCount, $bodies[0]); } elseif ($urlCount < $bodyCount) { $urls = \array_pad($urls, $bodyCount, $urls[0]); } $client = $this->client($timeout, $connectTimeout, multi: true); $responses = []; foreach ($urls as $i => $url) { $responses[] = $client->sendRequest($this->buildRequest($method, $url, $headers, $bodies[$i])); } return $responses; } /** * Build a client carrying the adapter's user agent and timeouts. When * $multi is set the cURL transport negotiates HTTP/2 and keeps the * connection alive so a batch of requests to the same host reuses it. */ private function client(int $timeout, int $connectTimeout, bool $multi = false): Client { $adapter = new CurlAdapter( options: $multi ? [\CURLOPT_HTTP_VERSION => \CURL_HTTP_VERSION_2_0] : [], ); return (new Client($adapter)) ->withTimeout((float) $timeout) ->withConnectTimeout((float) $connectTimeout) ->withConnectionReuse($multi) ->withHeaders([Header::USER_AGENT => "Appwrite {$this->getName()} Message Sender"]); } /** * Translate the legacy "Key: value" header list and body array into a * PSR-7 request, picking the body encoding from the Content-Type header. * * @param array<string> $headers * @param array<string, mixed>|null $body */ private function buildRequest(string $method, string $url, array $headers, ?array $body): RequestInterface { $factory = new RequestFactory(); $contentType = ''; $map = []; foreach ($headers as $header) { [$key, $value] = \array_pad(\explode(':', $header, 2), 2, ''); $key = \trim($key); $value = \trim($value); if (\strtolower($key) === 'content-type') { $contentType = \strtolower($value); continue; } $map[$key] = $value; } $body ??= []; return match (true) { \str_contains($contentType, 'application/x-www-form-urlencoded') => $factory->form($method, $url, $body, $map), \str_contains($contentType, 'multipart/form-data') => $factory->multipart($method, $url, $body, $map), default => $factory->json($method, $url, $body, $map), }; } /** * @param string $phone * @return int|null * @throws Exception */ public function getCountryCode(string $phone): ?int { if (empty($phone)) { throw new Exception('$phone cannot be empty.'); } $helper = PhoneNumberUtil::getInstance(); try { return $helper ->parse($phone) ->getCountryCode(); } catch (\Throwable $th) { throw new Exception("Error parsing phone: " . $th->getMessage()); } }} No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Batch requests are now sequential

requestMulti() now waits for every sendRequest() before starting the next one, removing the previous bounded concurrency and contradicting the documented concurrent HTTP/2 batch behavior. Large APNS and FCM sends therefore accumulate one request's latency per recipient.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Utopia/Messaging/Adapter.php
Line: 1

Comment:
**Batch requests are now sequential**

`requestMulti()` now waits for every `sendRequest()` before starting the next one, removing the previous bounded concurrency and contradicting the documented concurrent HTTP/2 batch behavior. Large APNS and FCM sends therefore accumulate one request's latency per recipient.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants