Skip to content
Merged
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
8 changes: 5 additions & 3 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ public function connect(): self
try {
if (!$this->client->connect($this->host, $this->port, $budget)) {
$this->invalidate();
throw new Exception("Failed to connect to MongoDB at {$this->host}:{$this->port}");
throw new UnsentException("Failed to connect to MongoDB at {$this->host}:{$this->port}");
}

$this->isConnected = true;
Expand Down Expand Up @@ -2178,13 +2178,15 @@ private function validateConnection(): void
}
}

// Both throws are raised ahead of the send, so the command never
// reached the server and a caller may safely replay it.
if (!$this->isConnected) {
throw new Exception('Client is not connected to MongoDB');
throw new UnsentException('Client is not connected to MongoDB');
}

if (!$this->client->isConnected()) {
$this->isConnected = false;
throw new Exception('Connection to MongoDB has been lost');
throw new UnsentException('Connection to MongoDB has been lost');
}
}

Expand Down
14 changes: 14 additions & 0 deletions src/Exception.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ public function isWriteConcernError(): bool
*
* @return bool
*/
/**
* Whether the command was still unsent when this failure was raised, so
* nothing was applied and the caller may replay it.
*
* False here on purpose: this type is what a server's own error response
* is parsed into, and that response carries the server's code. Only
* {@see UnsentException} — raised solely by the client, solely before it
* has sent anything — answers true.
*/
public function isUnsentError(): bool
{
return false;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

public function isTimeoutError(): bool
{
$timeoutCodes = [
Expand Down
25 changes: 25 additions & 0 deletions src/UnsentException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Utopia\Mongo;

/**
* A failure raised before the command reached the server.
*
* Connection validation and the dial that precedes it run ahead of every
* send, so a failure raised there leaves the command unsent and nothing
* applied — the one class of failure a caller may safely replay.
*
* The distinction is carried by the TYPE and nothing else. A message quotes
* caller-chosen values, so callers cannot classify on text; and an error code
* cannot prove it either, because a post-send error response carries the
* server's own code into an ordinary {@see Exception} — including codes like
* HostUnreachable that look pre-send. Only the client raises this type, and
* only before it has sent anything.
*/
class UnsentException extends Exception
{
public function isUnsentError(): bool
{
return true;
}
}
55 changes: 55 additions & 0 deletions tests/UnsentErrorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Utopia\Tests;

use PHPUnit\Framework\TestCase;
use Utopia\Mongo\Exception;
use Utopia\Mongo\UnsentException;

/**
* Connection validation and the dial before it run ahead of every send, so a
* failure raised there leaves the command unsent and nothing applied - the one
* class of failure a caller may safely replay. That distinction was previously
* legible only in the message text, and a cloud pool consequently surfaced a
* replayable refusal to clients as a 500 during a backing resize.
*
* It is carried by the type and nothing else. A code cannot carry it: a
* post-send error response is parsed into an ordinary Exception holding the
* SERVER's code, which includes codes that look pre-send.
*/
final class UnsentErrorTest extends TestCase
{
public function testAnUnsentFailureIsDistinguishableByType(): void
{
$unsent = new UnsentException('Client is not connected to MongoDB');

$this->assertTrue($unsent->isUnsentError());
$this->assertInstanceOf(Exception::class, $unsent, 'Callers that catch the package exception must still catch it');
}

public function testAPostSendTimeoutIsNotReportedAsUnsent(): void
{
$timeout = new Exception('Receive timeout: no data received within reasonable time', 11601);

$this->assertTrue($timeout->isTimeoutError());
$this->assertFalse(
$timeout->isUnsentError(),
'The command was already on the wire, so its outcome is unknown and it must never be replayed blindly',
);
}

/**
* The reason a code cannot carry this: the server picks it. A response
* that happens to report HostUnreachable is still a post-send answer, and
* replaying the operation it answered could apply it twice.
*/
public function testAServerErrorResponseIsNeverReportedAsUnsent(): void
{
$response = new \stdClass();
$response->code = 6;
$response->codeName = 'HostUnreachable';
$response->errmsg = 'host unreachable';

$this->assertFalse(Exception::fromResponse($response)->isUnsentError());
}
}
Loading