diff --git a/src/Client.php b/src/Client.php index 1718719..8c4c153 100644 --- a/src/Client.php +++ b/src/Client.php @@ -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; @@ -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'); } } diff --git a/src/Exception.php b/src/Exception.php index 7c47d6a..61953c2 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -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; + } + public function isTimeoutError(): bool { $timeoutCodes = [ diff --git a/src/UnsentException.php b/src/UnsentException.php new file mode 100644 index 0000000..686a6e8 --- /dev/null +++ b/src/UnsentException.php @@ -0,0 +1,25 @@ +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()); + } +}