Skip to content

Commit

Permalink
Add connect timeout support and improve the error messages from curl …
Browse files Browse the repository at this point in the history
…errors (#35)

* Add connect timeout support and improve the error messages from curl errors

* Fix tests

* Add cf ray and cache status to serialized exceptions

* Add host into json serialize
  • Loading branch information
acharron-hl committed Apr 10, 2024
1 parent 23cd0ad commit 49c8f2a
Show file tree
Hide file tree
Showing 6 changed files with 72 additions and 25 deletions.
1 change: 1 addition & 0 deletions src/CurlHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ protected function createCurl(HttpRequest $request) {
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $request->getTimeout());
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $request->getConnectTimeout());
curl_setopt($ch, CURLOPT_MAXREDIRS, 10);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_HEADER, true);
Expand Down
6 changes: 4 additions & 2 deletions src/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* Represents an client connection to a RESTful API.
*/
class HttpClient {

/// Properties ///

/**
Expand Down Expand Up @@ -325,7 +326,7 @@ public function getDefaultOption(string $name, $default = null) {
/**
* Set the value of a default option.
*
* @param string $name The name of the default option.
* @param string $name The name of the default option. One of the {@link HttpRequest::OPT_*} constants.
* @param mixed $value The new value of the default option.
* @return HttpClient Returns `$this` for fluent calls.
*/
Expand Down Expand Up @@ -366,7 +367,8 @@ public function setThrowExceptions(bool $throwExceptions) {
/**
* Set the default options.
*
* @param array $defaultOptions The new default options array.
* @param array<string, mixed> $defaultOptions The new default options array.
* Keys are the OPT_* constants from {@link HttpRequest::OPT_*}.
* @return HttpClient Returns `$this` for fluent calls.
*/
public function setDefaultOptions(array $defaultOptions) {
Expand Down
42 changes: 31 additions & 11 deletions src/HttpRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
class HttpRequest extends HttpMessage implements \JsonSerializable, RequestInterface {
/// Constants ///

public const OPT_TIMEOUT = "timeout";
public const OPT_CONNECT_TIMEOUT = "connectTimeout";
public const OPT_VERIFY_PEER = "verifyPeer";
public const OPT_PROTOCOL_VERSION = "protocolVersion";
public const OPT_AUTH = "auth";

const METHOD_DELETE = 'DELETE';
const METHOD_GET = 'GET';
const METHOD_HEAD = 'HEAD';
Expand Down Expand Up @@ -48,6 +54,9 @@ class HttpRequest extends HttpMessage implements \JsonSerializable, RequestInter
*/
protected $timeout = 0;

/** @var int */
protected $connectTimeout = 0;

/**
* @var bool
*/
Expand Down Expand Up @@ -78,17 +87,11 @@ public function __construct(string $method = self::METHOD_GET, string $url = '',
$this->setBody($body);
$this->setHeaders($headers);

$options += [
'protocolVersion' => '1.1',
'auth' => [],
'timeout' => 0,
'verifyPeer' => true
];

$this->setProtocolVersion($options['protocolVersion']);
$this->setAuth($options['auth']);
$this->setVerifyPeer($options['verifyPeer']);
$this->setTimeout($options['timeout']);
$this->setProtocolVersion($options[self::OPT_PROTOCOL_VERSION] ?? "1.1");
$this->setAuth($options[self::OPT_AUTH] ?? []);
$this->setVerifyPeer($options[self::OPT_VERIFY_PEER] ?? true);
$this->setConnectTimeout($options[self::OPT_CONNECT_TIMEOUT] ?? 0);
$this->setTimeout($options[self::OPT_TIMEOUT] ?? 0);
}

/**
Expand Down Expand Up @@ -228,6 +231,22 @@ public function setTimeout(int $timeout) {
return $this;
}

/**
* @return int
*/
public function getConnectTimeout(): int {
return $this->connectTimeout;
}

/**
* @param int $connectTimeout
* @return void
*/
public function setConnectTimeout(int $connectTimeout): HttpRequest {
$this->connectTimeout = $connectTimeout;
return $this;
}

/**
* Get constructor options as an array.
*
Expand All @@ -252,6 +271,7 @@ public function getOptions(): array {
public function jsonSerialize(): array {
return [
"url" => $this->getUrl(),
"host" => $this->getHeader("host") ?: $this->getUri()->getHost(),
"method" => $this->getMethod(),
];
}
Expand Down
11 changes: 10 additions & 1 deletion src/HttpResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,14 @@ public function setStatusCode(int $statusCode) {
* @return string Returns the reason phrase.
*/
public function getReasonPhrase(): string {
return $this->reasonPhrase;
if ($this->statusCode === 0 && !empty($this->rawBody)) {
// CURL often returns a 0 error code if it failed to connect.
// This could be for multiple reasons. We need the actual message provided to differentiate between
// a timeout vs a DNS resolution failure.
return $this->rawBody;
} else {
return $this->reasonPhrase;
}
}

/**
Expand Down Expand Up @@ -478,6 +485,8 @@ public function jsonSerialize(): array {
"content-type" => $this->getHeader("content-type") ?: null,
"request" => $this->getRequest(),
"body" => $this->getRawBody(),
"cf-ray" => $this->getHeader("cf-ray") ?: null,
"cf-cache-status" => $this->getHeader("cf-cache-status") ?: null,
];
}

Expand Down
26 changes: 22 additions & 4 deletions tests/HttpResponseExceptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,41 @@ class HttpResponseExceptionTest extends TestCase {
* Test json serialize implementation of exceptions.
*/
public function testJsonSerialize(): void {
$response = new HttpResponse(501, ["content-type" => "application/json"], '{"message":"Some error occured."}');
$response->setRequest(new HttpRequest("POST", "/some/path"));
$response = new HttpResponse(501, ["content-type" => "application/json", "Cf-Ray" => "ray-id-12345"], '{"message":"Some error occured."}');
$response->setRequest(new HttpRequest("POST", "https://somesite.com/some/path"));
$this->assertEquals([
"message" => 'Request "POST /some/path" failed with a response code of 501 and a custom message of "Some error occured."',
"message" => 'Request "POST https://somesite.com/some/path" failed with a response code of 501 and a custom message of "Some error occured."',
"status" => 501,
"code" => 501,
"request" => [
'url' => '/some/path',
'url' => 'https://somesite.com/some/path',
"host" => "somesite.com",
'method' => 'POST',
],
"response" => [
'statusCode' => 501,
'content-type' => 'application/json',
'body' => '{"message":"Some error occured."}',
"cf-ray" => "ray-id-12345",
"cf-cache-status" => null,
],
'class' => 'Garden\Http\HttpResponseException',
], $response->asException()->jsonSerialize());
}

/**
* @return void
*/
public function testHostSiteOverrideSerialize() {
$request = new HttpRequest("GET", "https://proxy-server.com/some/path", ["Host" => "example.com"]);
$serialized = $request->jsonSerialize();
$this->assertEquals([
"url" => "https://proxy-server.com/some/path",
"host" => "proxy-server.com",
"method" => "GET",
], $serialized);
}

/**
* Test that we can create exceptions for requests without responses.
*
Expand All @@ -55,6 +71,8 @@ public function testExceptionWithNoRequest() {
'statusCode' => 500,
'content-type' => 'application/json',
'body' => '{"error":"hi"}',
"cf-ray" => null,
"cf-cache-status" => null,
],
'request' => null,
], $response->asException()->jsonSerialize());
Expand Down
11 changes: 4 additions & 7 deletions tests/ReadmeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,16 @@
class ReadmeTest extends TestCase {
public function testBasicExample() {
$api = new HttpClient('http://httpbin.org');
$api->setThrowExceptions(true);
$api->setDefaultHeader('Content-Type', 'application/json');

// Get some data from the API.
$response = $api->get('/get'); // requests off of base url
if ($response->isSuccessful()) {
$data = $response->getBody(); // returns array of json decoded data
}
$data = $response->getBody(); // returns array of json decoded data

$response = $api->post('https://httpbin.org/post', ['foo' => 'bar']);
if ($response->isResponseClass('2xx')) {
// Access the response like an array.
$posted = $response['json']; // should be ['foo' => 'bar']
}
// Access the response like an array.
$posted = $response['json']; // should be ['foo' => 'bar']

if (!$response->isSuccessful()) {
$this->markTestSkipped();
Expand Down

0 comments on commit 49c8f2a

Please sign in to comment.