diff --git a/clients/algoliasearch-client-java/algoliasearch/src/main/java/com/algolia/internal/interceptors/RetryStrategy.java b/clients/algoliasearch-client-java/algoliasearch/src/main/java/com/algolia/internal/interceptors/RetryStrategy.java index 71c0a2f55d..caa4cbd1cb 100644 --- a/clients/algoliasearch-client-java/algoliasearch/src/main/java/com/algolia/internal/interceptors/RetryStrategy.java +++ b/clients/algoliasearch-client-java/algoliasearch/src/main/java/com/algolia/internal/interceptors/RetryStrategy.java @@ -84,6 +84,9 @@ private Response handleResponse(StatefulHost host, @Nonnull Response response) t try { String message = response.body() != null ? response.body().string() : response.message(); + if (response.header("Content-Type", "application/json").contains("text/html")) { + message = response.message(); + } throw isRetryable(response) ? new AlgoliaRequestException(message, response.code()) : new AlgoliaApiException(message, response.code()); diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/transporter/helpers.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/transporter/helpers.ts index 25cd25ae68..89fb7b0670 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/transporter/helpers.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/transporter/helpers.ts @@ -82,6 +82,29 @@ export function deserializeSuccess(response: Response): TObject { } } +const httpMessages: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Required', + 413: 'Request Entry Too Large', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Requested Range Not Satisfiable', + 417: 'Expectation Failed', + 418: 'I\'m a teapot', + 429: 'Too Many Requests', +} + export function deserializeFailure({ content, status }: Response, stackFrame: StackFrame[]): Error { try { const parsed = JSON.parse(content); @@ -92,5 +115,5 @@ export function deserializeFailure({ content, status }: Response, stackFrame: St } catch { // .. } - return new ApiError(content, status, stackFrame); + return new ApiError(status in httpMessages ? httpMessages[status] : content, status, stackFrame); } diff --git a/clients/algoliasearch-client-php/lib/Http/CurlHttpClient.php b/clients/algoliasearch-client-php/lib/Http/CurlHttpClient.php index b1f11d38d8..add7b1bf93 100644 --- a/clients/algoliasearch-client-php/lib/Http/CurlHttpClient.php +++ b/clients/algoliasearch-client-php/lib/Http/CurlHttpClient.php @@ -116,11 +116,12 @@ public function sendRequest( $statusCode = (int) curl_getinfo($curlHandle, CURLINFO_HTTP_CODE); $responseBody = curl_multi_getcontent($curlHandle); $error = curl_error($curlHandle); + $contentType = curl_getinfo($curlHandle, CURLINFO_CONTENT_TYPE); $this->releaseMHandle($curlHandle); curl_close($curlHandle); - return new Response($statusCode, [], $responseBody, '1.1', $error); + return new Response($statusCode, ['Content-Type' => $contentType], $responseBody, '1.1', $error); } private function getMHandle($curlHandle) diff --git a/clients/algoliasearch-client-php/lib/RetryStrategy/ApiWrapper.php b/clients/algoliasearch-client-php/lib/RetryStrategy/ApiWrapper.php index d194b02ed9..4f9bb1845d 100644 --- a/clients/algoliasearch-client-php/lib/RetryStrategy/ApiWrapper.php +++ b/clients/algoliasearch-client-php/lib/RetryStrategy/ApiWrapper.php @@ -247,6 +247,11 @@ private function handleResponse( throw new RetriableException('Retriable failure on '.$request->getUri()->getHost().': '.$reason, $statusCode); } + // handle HTML error responses + if (false !== strpos($response->getHeaderLine('Content-Type'), 'text/html')) { + throw new AlgoliaException($statusCode.': '.$response->getReasonPhrase(), $statusCode); + } + $responseArray = Helpers::json_decode($body, true); if (404 === $statusCode) { diff --git a/clients/algoliasearch-client-ruby/lib/algolia/error.rb b/clients/algoliasearch-client-ruby/lib/algolia/error.rb index 7a744e90c7..1b03741d2b 100644 --- a/clients/algoliasearch-client-ruby/lib/algolia/error.rb +++ b/clients/algoliasearch-client-ruby/lib/algolia/error.rb @@ -27,12 +27,12 @@ def initialize(message, errors = []) # which is also included in the response attribute. # class AlgoliaHttpError < AlgoliaError - attr_accessor :code, :message + attr_accessor :code, :http_message def initialize(code, message) self.code = code - self.message = message - super("#{self.code()}: #{self.message()}") + self.http_message = message + super("#{code}: #{message}") end end end diff --git a/clients/algoliasearch-client-ruby/lib/algolia/transport/http/http_requester.rb b/clients/algoliasearch-client-ruby/lib/algolia/transport/http/http_requester.rb index 318b0e69da..1a7c363556 100644 --- a/clients/algoliasearch-client-ruby/lib/algolia/transport/http/http_requester.rb +++ b/clients/algoliasearch-client-ruby/lib/algolia/transport/http/http_requester.rb @@ -39,14 +39,14 @@ def send_request(host, method, path, body, query_params, headers, timeout, conne @logger.info("Request succeeded. Response status: #{response.status}, body: #{response.body}") end - return Http::Response.new(status: response.status, body: response.body, headers: response.headers) + return Http::Response.new(status: response.status, reason_phrase: response.reason_phrase, body: response.body, headers: response.headers) end if ENV["ALGOLIA_DEBUG"] @logger.info("Request failed. Response status: #{response.status}, error: #{response.body}") end - Http::Response.new(status: response.status, error: response.body, headers: response.headers) + Http::Response.new(status: response.status, reason_phrase: response.reason_phrase, error: response.body, headers: response.headers) rescue Faraday::TimeoutError => e @logger.info("Request timed out. Error: #{e.message}") if ENV["ALGOLIA_DEBUG"] Http::Response.new(error: e.message, has_timed_out: true) diff --git a/clients/algoliasearch-client-ruby/lib/algolia/transport/http/response.rb b/clients/algoliasearch-client-ruby/lib/algolia/transport/http/response.rb index fb8c878cc0..cc6dea154d 100644 --- a/clients/algoliasearch-client-ruby/lib/algolia/transport/http/response.rb +++ b/clients/algoliasearch-client-ruby/lib/algolia/transport/http/response.rb @@ -1,13 +1,14 @@ module Algolia module Http class Response - attr_reader :status, :body, :error, :headers, :has_timed_out, :network_failure + attr_reader :status, :reason_phrase, :body, :error, :headers, :has_timed_out, :network_failure # used for the echo requester attr_reader :method, :path, :query_params, :host, :timeout, :connect_timeout # # @option status [String] Response status + # @option reason_phrase [String] Response reason phrase # @option body [String] Response body # @option error [String] Response error or caught error # @option headers [String] Response headers @@ -15,6 +16,7 @@ class Response # def initialize(opts = {}) @status = opts[:status] + @reason_phrase = opts[:reason_phrase] @body = opts[:body] @error = opts[:error] || "" @headers = opts[:headers] || "" diff --git a/clients/algoliasearch-client-ruby/lib/algolia/transport/transport.rb b/clients/algoliasearch-client-ruby/lib/algolia/transport/transport.rb index 14c7e4169b..8c190b88fc 100644 --- a/clients/algoliasearch-client-ruby/lib/algolia/transport/transport.rb +++ b/clients/algoliasearch-client-ruby/lib/algolia/transport/transport.rb @@ -69,6 +69,11 @@ def request(call_type, method, path, body, opts = {}) network_failure: response.network_failure ) if outcome == FAILURE + # handle HTML error + if response.headers["content-type"]&.include?("text/html") + raise Algolia::AlgoliaHttpError.new(response.status, response.reason_phrase) + end + decoded_error = JSON.parse(response.error, :symbolize_names => true) raise Algolia::AlgoliaHttpError.new(response.status, decoded_error[:message]) end diff --git a/scripts/cts/testServer/timeout.ts b/scripts/cts/testServer/timeout.ts index 83778ff684..e81902548a 100644 --- a/scripts/cts/testServer/timeout.ts +++ b/scripts/cts/testServer/timeout.ts @@ -97,6 +97,11 @@ function addRoutes(app: express.Express): void { // no response, just hang }); + + app.get('/1/html-error', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.status(429).send('429 Too Many Requests'); + }); } export function timeoutServer(): Promise { diff --git a/templates/ruby/tests/client/tests.mustache b/templates/ruby/tests/client/tests.mustache index d3250475db..430c581c0c 100644 --- a/templates/ruby/tests/client/tests.mustache +++ b/templates/ruby/tests/client/tests.mustache @@ -18,7 +18,7 @@ {{#dynamicTemplate}}{{/dynamicTemplate}} assert(false, 'An error should have been raised') rescue => e - assert_equal({{#lambda.codeSnakeCase}}'{{{expectedError}}}'{{/lambda.codeSnakeCase}}.sub('%localhost%', ENV.fetch('CI', nil) == 'true' ? 'localhost' : 'host.docker.internal'), e.message) + assert_equal({{#lambda.codeSnakeCase}}%q({{{expectedError}}}){{/lambda.codeSnakeCase}}.sub('%localhost%', ENV.fetch('CI', nil) == 'true' ? 'localhost' : 'host.docker.internal'), e.message) end {{/isError}} {{^isError}} diff --git a/tests/CTS/client/ingestion/api.json b/tests/CTS/client/ingestion/api.json new file mode 100644 index 0000000000..fb04add37d --- /dev/null +++ b/tests/CTS/client/ingestion/api.json @@ -0,0 +1,42 @@ +[ + { + "testName": "can handle HTML error", + "autoCreateClient": false, + "steps": [ + { + "type": "createClient", + "parameters": { + "appId": "test-app-id", + "apiKey": "test-api-key", + "region": "us", + "customHosts": [ + { + "port": 6676 + } + ] + } + }, + { + "type": "method", + "method": "customGet", + "parameters": { + "path": "1/html-error" + }, + "expected": { + "error": { + "csharp": "429 too many requests", + "go": "API error [429] Too Many Requests", + "java": "Status Code: 429 - Too Many Requests", + "javascript": "Too Many Requests", + "kotlin": "Client request(GET http://%localhost%:6676/1/html-error) invalid: 429 Too Many Requests. Text: \\\"429 Too Many Requests\\\"", + "php": "429: Too Many Requests", + "python": "Too Many Requests", + "ruby": "429: Too Many Requests", + "scala": "429 Too Many Requests", + "swift": "HTTP error: Status code: 429 Message: No message" + } + } + } + ] + } +] diff --git a/tests/CTS/client/search/indexExists.json b/tests/CTS/client/search/indexExists.json index fab0be4263..f2582333ee 100644 --- a/tests/CTS/client/search/indexExists.json +++ b/tests/CTS/client/search/indexExists.json @@ -88,7 +88,7 @@ "kotlin": "Client request(GET http://%localhost%:6681/1/indexes/indexExistsERROR/settings) invalid: 403 Forbidden. Text: \\\"{\\\"message\\\":\\\"Invalid API key\\\"}\\\"", "php": "Invalid API key", "python": "Invalid API key", - "ruby": "Invalid API key", + "ruby": "403: Invalid API key", "swift": "HTTP error: Status code: 403 Message: Invalid API key" } } diff --git a/tests/CTS/client/search/saveObjects.json b/tests/CTS/client/search/saveObjects.json index 2c594f6424..060d85bd9b 100644 --- a/tests/CTS/client/search/saveObjects.json +++ b/tests/CTS/client/search/saveObjects.json @@ -87,8 +87,7 @@ "kotlin": "Client request(POST http://%localhost%:6680/1/indexes/cts_e2e_saveObjects_kotlin/batch) invalid: 403 Forbidden. Text: \\\"{\\\"message\\\":\\\"Invalid Application-ID or API key\\\",\\\"status\\\":403}\\\"", "php": "Invalid Application-ID or API key", "python": "Invalid Application-ID or API key", - "ruby": "Invalid Application-ID or API key", - "scala": "Invalid Application-ID or API key", + "ruby": "403: Invalid Application-ID or API key", "swift": "HTTP error: Status code: 403 Message: Invalid Application-ID or API key" } } diff --git a/tests/output/scala/src/test/scala/algoliasearch/package.scala b/tests/output/scala/src/test/scala/algoliasearch/package.scala index 41383712fa..8166dfcb46 100644 --- a/tests/output/scala/src/test/scala/algoliasearch/package.scala +++ b/tests/output/scala/src/test/scala/algoliasearch/package.scala @@ -10,7 +10,10 @@ package object algoliasearch { def assertError(message: String)(call: => Unit)(implicit ec: ExecutionContextExecutor): Unit = { val error = intercept[Exception](call) - assert(error.getMessage == message) + assert( + error.getMessage == message, + s"Error message does not match, expected: $message, got: ${error.getMessage}" + ) } @targetName("assertErrorFuture")