diff --git a/.github/workflows/phpunit-oci.yml b/.github/workflows/phpunit-oci.yml index 3f9c5c9071f..9b792b79adb 100644 --- a/.github/workflows/phpunit-oci.yml +++ b/.github/workflows/phpunit-oci.yml @@ -34,7 +34,7 @@ concurrency: jobs: phpunit-oci: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 strategy: matrix: diff --git a/lib/BackgroundJob/CheckHostedSignalingServer.php b/lib/BackgroundJob/CheckHostedSignalingServer.php index da1d5d95eb5..1a02bed99d4 100644 --- a/lib/BackgroundJob/CheckHostedSignalingServer.php +++ b/lib/BackgroundJob/CheckHostedSignalingServer.php @@ -28,6 +28,7 @@ use OCA\Talk\DataObjects\AccountId; use OCA\Talk\Exceptions\HostedSignalingServerAPIException; use OCA\Talk\Service\HostedSignalingServerService; +use OCP\AppFramework\Http; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; use OCP\BackgroundJob\TimedJob; @@ -79,9 +80,17 @@ protected function run($argument): void { $accountId = new AccountId($accountId); try { $accountInfo = $this->hostedSignalingServerService->fetchAccountInfo($accountId); - } catch (HostedSignalingServerAPIException $e) { // API or connection issues - // do nothing and just try again later - return; + } catch (HostedSignalingServerAPIException $e) { + if ($e->getCode() === Http::STATUS_NOT_FOUND) { + // Account was deleted, so remove the information locally + $accountInfo = ['status' => 'deleted']; + } elseif ($e->getCode() === Http::STATUS_UNAUTHORIZED) { + // Account is expired and deletion is pending unless it's reactivated. + $accountInfo = ['status' => 'expired']; + } else { + // API or connection issues - do nothing and just try again later + return; + } } $oldStatus = $oldAccountInfo['status'] ?? ''; @@ -92,15 +101,13 @@ protected function run($argument): void { // the status has changed if ($oldStatus !== $newStatus) { - if ($oldStatus === 'active') { + if ($newStatus === 'deleted') { // remove signaling servers if account is not active anymore $this->config->deleteAppValue('spreed', 'signaling_mode'); $this->config->deleteAppValue('spreed', 'signaling_servers'); $notificationSubject = 'removed'; - } - - if ($newStatus === 'active') { + } elseif ($newStatus === 'active') { // add signaling servers if account got active $this->config->deleteAppValue('spreed', 'signaling_mode'); $this->config->setAppValue('spreed', 'signaling_servers', json_encode([ diff --git a/lib/Controller/HostedSignalingServerController.php b/lib/Controller/HostedSignalingServerController.php index cd49296c083..55b6d15c541 100644 --- a/lib/Controller/HostedSignalingServerController.php +++ b/lib/Controller/HostedSignalingServerController.php @@ -112,19 +112,25 @@ public function deleteAccount(): DataResponse { try { $this->hostedSignalingServerService->deleteAccount(new AccountId($accountId)); + } catch (HostedSignalingServerAPIException $e) { + if ($e->getCode() === Http::STATUS_NOT_FOUND) { + // Account was deleted, so remove the information locally + } elseif ($e->getCode() === Http::STATUS_UNAUTHORIZED) { + // Account is expired and deletion is pending unless it's reactivated. + } else { + // API or connection issues - do nothing and just try again later + return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } - $this->config->deleteAppValue('spreed', 'hosted-signaling-server-account'); - $this->config->deleteAppValue('spreed', 'hosted-signaling-server-account-id'); - - // remove signaling servers if account is not active anymore - $this->config->deleteAppValue('spreed', 'signaling_mode'); - $this->config->deleteAppValue('spreed', 'signaling_servers'); + $this->config->deleteAppValue('spreed', 'hosted-signaling-server-account'); + $this->config->deleteAppValue('spreed', 'hosted-signaling-server-account-id'); - $this->logger->info('Deleted hosted signaling server account with ID ' . $accountId); - } catch (HostedSignalingServerAPIException $e) { // API or connection issues - return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); - } + // remove signaling servers if account is not active anymore + $this->config->deleteAppValue('spreed', 'signaling_mode'); + $this->config->deleteAppValue('spreed', 'signaling_servers'); + $this->logger->info('Deleted hosted signaling server account with ID ' . $accountId); return new DataResponse([], Http::STATUS_NO_CONTENT); } diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 0470b79e3d2..98a4716ab6e 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -922,30 +922,57 @@ protected function parseHostedSignalingServer(INotification $notification, IL10N ->setLink($notification->getLink(), IAction::TYPE_WEB) ->setPrimary(true); + $parsedParameters = []; + $icon = ''; switch ($notification->getSubject()) { case 'added': - $subject = $l->t('The hosted signaling server is now configured and will be used.'); + $subject = $l->t('Hosted signaling server added'); + $message = $l->t('The hosted signaling server is now configured and will be used.'); + $icon = $this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/video.svg')); break; case 'removed': - $subject = $l->t('The hosted signaling server was removed and will not be used anymore.'); + $subject = $l->t('Hosted signaling server removed'); + $message = $l->t('The hosted signaling server was removed and will not be used anymore.'); + $icon = $this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/video-off.svg')); break; case 'changed-status': - $subject = $l->t('The hosted signaling server account has changed the status from "{oldstatus}" to "{newstatus}".'); + $subject = $l->t('Hosted signaling server changed'); + $message = $l->t('The hosted signaling server account has changed the status from "{oldstatus}" to "{newstatus}".'); + $icon = $this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/video-switch.svg')); $parameters = $notification->getSubjectParameters(); - $subject = str_replace( - ['{oldstatus}', '{newstatus}'], - [$parameters['oldstatus'], $parameters['newstatus']], - $subject - ); + $parsedParameters = [ + 'oldstatus' => $this->createHPBParameter($parameters['oldstatus'], $l), + 'newstatus' => $this->createHPBParameter($parameters['newstatus'], $l), + ]; break; default: throw new \InvalidArgumentException('Unknown subject'); } return $notification - ->setParsedSubject($subject) - ->setIcon($notification->getIcon()) + ->setRichSubject($subject) + ->setRichMessage($message, $parsedParameters) + ->setIcon($icon) ->addParsedAction($action); } + + protected function hostedHPBStatusToLabel(string $status, IL10N $l): string { + return match ($status) { + 'pending' => $l->t('pending'), + 'active' => $l->t('active'), + 'expired' => $l->t('expired'), + 'blocked' => $l->t('blocked'), + 'error' => $l->t('error'), + default => $status, + }; + } + + protected function createHPBParameter(string $status, IL10N $l): array { + return [ + 'type' => 'highlight', + 'id' => $status, + 'name' => $this->hostedHPBStatusToLabel($status, $l), + ]; + } } diff --git a/lib/Service/HostedSignalingServerService.php b/lib/Service/HostedSignalingServerService.php index bc72a287b57..b15103f8dcd 100644 --- a/lib/Service/HostedSignalingServerService.php +++ b/lib/Service/HostedSignalingServerService.php @@ -26,6 +26,7 @@ namespace OCA\Talk\Service; use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\ServerException; use OCA\Talk\DataObjects\AccountId; use OCA\Talk\DataObjects\RegisterAccountData; use OCA\Talk\Exceptions\HostedSignalingServerAPIException; @@ -37,6 +38,9 @@ use OCP\Security\ISecureRandom; use Psr\Log\LoggerInterface; +/** + * API documentation at https://gitlab.com/strukturag/spreed-hpbservice/-/blob/master/doc/API.md + */ class HostedSignalingServerService { private IConfig $config; /** @var mixed */ @@ -96,7 +100,7 @@ public function registerAccount(RegisterAccountData $registerAccountData): Accou if ($response === null) { $this->logger->error('Failed to request hosted signaling server trial', ['exception' => $e]); $message = $this->l10n->t('Failed to request trial because the trial server is unreachable. Please try again later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR); } $status = $response->getStatusCode(); @@ -106,7 +110,7 @@ public function registerAccount(RegisterAccountData $registerAccountData): Accou $this->logger->error('Requesting hosted signaling server trial failed: unauthorized - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('There is a problem with the authentication of this instance. Maybe it is not reachable from the outside to verify it\'s URL.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_BAD_REQUEST: $body = $response->getBody()->getContents(); if ($body) { @@ -115,7 +119,7 @@ public function registerAccount(RegisterAccountData $registerAccountData): Accou $this->logger->error('Requesting hosted signaling server trial failed: cannot parse JSON response - JSON error: '. json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } if ($parsedBody['reason']) { $message = ''; @@ -165,46 +169,46 @@ public function registerAccount(RegisterAccountData $registerAccountData): Accou // user error if ($message !== '') { $this->logger->warning('Requesting hosted signaling server trial failed: bad request - reason: ' . $parsedBody['reason'] . ' ' . $log); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } $this->logger->error('Requesting hosted signaling server trial failed: bad request - reason: ' . $parsedBody['reason'] . ' ' . $log); $message = $this->l10n->t('There is a problem with the request of the trial. Please check your logs for further information.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } } $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_TOO_MANY_REQUESTS: $body = $response->getBody()->getContents(); $this->logger->error('Requesting hosted signaling server trial failed: too many requests - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Too many requests are send from your servers address. Please try again later.'); - throw new HostedSignalingServerInputException($message); + throw new HostedSignalingServerInputException($message, $status); case Http::STATUS_CONFLICT: $body = $response->getBody()->getContents(); $this->logger->error('Requesting hosted signaling server trial failed: already registered - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('There is already a trial registered for this Nextcloud instance.'); - throw new HostedSignalingServerInputException($message); + throw new HostedSignalingServerInputException($message, $status); case Http::STATUS_INTERNAL_SERVER_ERROR: $body = $response->getBody()->getContents(); $this->logger->error('Requesting hosted signaling server trial failed: internal server error - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened. Please try again later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); default: $body = $response->getBody()->getContents(); $this->logger->error('Requesting hosted signaling server trial failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Failed to request trial because the trial server behaved wrongly. Please try again later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } } catch (\Exception $e) { $this->logger->error('Failed to request hosted signaling server trial', ['exception' => $e]); $message = $this->l10n->t('Failed to request trial because the trial server is unreachable. Please try again later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, ($e instanceof ServerException ? $e->getResponse()?->getStatusCode() : null) ?? Http::STATUS_INTERNAL_SERVER_ERROR); } $status = $response->getStatusCode(); @@ -214,7 +218,7 @@ public function registerAccount(RegisterAccountData $registerAccountData): Accou $this->logger->error('Requesting hosted signaling server trial failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } $body = $response->getBody(); @@ -224,14 +228,14 @@ public function registerAccount(RegisterAccountData $registerAccountData): Accou $this->logger->error('Requesting hosted signaling server trial failed: cannot parse JSON response - JSON error: '. json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR); } if (!isset($data['account_id'])) { $this->logger->error('Requesting hosted signaling server trial failed: no account ID transfered - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR); } $accountId = (string)$data['account_id']; @@ -270,7 +274,7 @@ public function fetchAccountInfo(AccountId $accountId) { if ($response === null) { $this->logger->error('Trial requested but failed to get account information', ['exception' => $e]); $message = $this->l10n->t('Trial requested but failed to get account information. Please check back later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR); } $status = $response->getStatusCode(); @@ -281,7 +285,7 @@ public function fetchAccountInfo(AccountId $accountId) { $this->logger->error('Getting the account information failed: unauthorized - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('There is a problem with the authentication of this request. Maybe it is not reachable from the outside to verify it\'s URL.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_BAD_REQUEST: $body = $response->getBody()->getContents(); if ($body) { @@ -290,7 +294,7 @@ public function fetchAccountInfo(AccountId $accountId) { $this->logger->error('Getting the account information failed: cannot parse JSON response - JSON error: '. json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } if ($parsedBody['reason']) { switch ($parsedBody['reason']) { @@ -302,46 +306,46 @@ public function fetchAccountInfo(AccountId $accountId) { $this->logger->error('Getting the account information failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Failed to fetch account information because the trial server behaved wrongly. Please check back later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } $this->logger->error('Getting the account information failed: bad request - reason: ' . $parsedBody['reason'] . ' ' . $log); $message = $this->l10n->t('There is a problem with fetching the account information. Please check your logs for further information.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } } $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_TOO_MANY_REQUESTS: $body = $response->getBody()->getContents(); $this->logger->error('Getting the account information failed: too many requests - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Too many requests are send from your servers address. Please try again later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_NOT_FOUND: $body = $response->getBody()->getContents(); $this->logger->error('Getting the account information failed: account not found - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('There is no such account registered.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_INTERNAL_SERVER_ERROR: $body = $response->getBody()->getContents(); $this->logger->error('Getting the account information failed: internal server error - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened. Please try again later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); default: $body = $response->getBody()->getContents(); $this->logger->error('Getting the account information failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Failed to fetch account information because the trial server behaved wrongly. Please check back later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } } catch (\Exception $e) { $this->logger->error('Failed to request hosted signaling server trial', ['exception' => $e]); $message = $this->l10n->t('Failed to fetch account information because the trial server is unreachable. Please check back later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, ($e instanceof ServerException ? $e->getResponse()?->getStatusCode() : null) ?? Http::STATUS_INTERNAL_SERVER_ERROR); } $status = $response->getStatusCode(); @@ -352,7 +356,7 @@ public function fetchAccountInfo(AccountId $accountId) { $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } $body = $response->getBody(); @@ -362,7 +366,7 @@ public function fetchAccountInfo(AccountId $accountId) { $this->logger->error('Getting the account information failed: cannot parse JSON response - JSON error: '. json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR); } if (!isset($data['status']) @@ -392,7 +396,7 @@ public function fetchAccountInfo(AccountId $accountId) { $this->logger->error('Getting the account information failed: response is missing mandatory field - data: ' . json_encode($data)); $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR); } return $data; @@ -426,7 +430,7 @@ public function deleteAccount(AccountId $accountId): void { if ($response === null) { $this->logger->error('Deleting the hosted signaling server account failed', ['exception' => $e]); $message = $this->l10n->t('Deleting the hosted signaling server account failed. Please check back later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, Http::STATUS_INTERNAL_SERVER_ERROR); } $status = $response->getStatusCode(); @@ -437,7 +441,7 @@ public function deleteAccount(AccountId $accountId): void { $this->logger->error('Deleting the hosted signaling server account failed: unauthorized - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('There is a problem with the authentication of this request. Maybe it is not reachable from the outside to verify it\'s URL.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_BAD_REQUEST: $body = $response->getBody()->getContents(); if ($body) { @@ -446,7 +450,7 @@ public function deleteAccount(AccountId $accountId): void { $this->logger->error('Deleting the hosted signaling server account failed: cannot parse JSON response - JSON error: '. json_last_error() . ' ' . json_last_error_msg() . ' HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } if ($parsedBody['reason']) { switch ($parsedBody['reason']) { @@ -458,46 +462,46 @@ public function deleteAccount(AccountId $accountId): void { $this->logger->error('Deleting the hosted signaling server account failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Failed to delete the account because the trial server behaved wrongly. Please check back later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } $this->logger->error('Deleting the hosted signaling server account failed: bad request - reason: ' . $parsedBody['reason'] . ' ' . $log); $message = $this->l10n->t('There is a problem with deleting the account. Please check your logs for further information.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } } $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_TOO_MANY_REQUESTS: $body = $response->getBody()->getContents(); $this->logger->error('Deleting the hosted signaling server account failed: too many requests - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Too many requests are sent from your servers address. Please try again later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_NOT_FOUND: $body = $response->getBody()->getContents(); $this->logger->error('Deleting the hosted signaling server account failed: account not found - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('There is no such account registered.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); case Http::STATUS_INTERNAL_SERVER_ERROR: $body = $response->getBody()->getContents(); $this->logger->error('Deleting the hosted signaling server account failed: internal server error - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Something unexpected happened. Please try again later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); default: $body = $response->getBody()->getContents(); $this->logger->error('Deleting the hosted signaling server account failed: something else happened - HTTP status: ' . $status . ' Response body: ' . $body); $message = $this->l10n->t('Failed to delete the account because the trial server behaved wrongly. Please check back later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } } catch (\Exception $e) { $this->logger->error('Deleting the hosted signaling server account failed', ['exception' => $e]); $message = $this->l10n->t('Failed to delete the account because the trial server is unreachable. Please check back later.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, ($e instanceof ServerException ? $e->getResponse()?->getStatusCode() : null) ?? Http::STATUS_INTERNAL_SERVER_ERROR); } $status = $response->getStatusCode(); @@ -508,7 +512,7 @@ public function deleteAccount(AccountId $accountId): void { $message = $this->l10n->t('Something unexpected happened.'); - throw new HostedSignalingServerAPIException($message); + throw new HostedSignalingServerAPIException($message, $status); } } } diff --git a/tests/stubs/GuzzleHttp_Exception_ServerException.php b/tests/stubs/GuzzleHttp_Exception_ServerException.php index d9015ff308d..dd65c9d0543 100644 --- a/tests/stubs/GuzzleHttp_Exception_ServerException.php +++ b/tests/stubs/GuzzleHttp_Exception_ServerException.php @@ -3,4 +3,9 @@ namespace GuzzleHttp\Exception; class ServerException extends \RuntimeException { + public function getResponse() { + } + + public function hasResponse(): bool { + } }