%% Copyright 2019 Ipregistry (https://ipregistry.co). %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. %% You may obtain a copy of the License at %% %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, software %% distributed under the License is distributed on an "AS IS" BASIS, %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. %% @private HTTP machinery for the Ipregistry client: request building, %% automatic retries with exponential backoff, and error decoding. Built on %% OTP's `httpc' with explicit TLS peer verification. -module(ipregistry_http). -export([request/4, to_api_error/1]). -ifdef(TEST). -export([parse_error_body/2, retry_after_ms/1]). -endif. %% @doc Performs an API request and decodes the JSON response. %% %% `PathWithQuery' is appended to the client's base URL. `Body' is either %% `undefined' (no request body) or an already-encoded JSON binary. %% Transient failures (transport errors, and 5xx or 429 responses depending %% on the client's retry configuration) are retried with exponential %% backoff, honoring the `Retry-After' header when present. -spec request(ipregistry:client(), get | post, binary(), binary() | undefined) -> {ok, term()} | {error, ipregistry:error_reason()}. request(Client, Method, PathWithQuery, Body) -> #{base_url := BaseUrl, api_key := ApiKey, user_agent := UserAgent} = Client, Url = binary_to_list(<>), Headers = [ {"authorization", "ApiKey " ++ binary_to_list(ApiKey)}, {"user-agent", binary_to_list(UserAgent)}, {"accept", "application/json"} ], attempt(Client, Method, Url, Headers, Body, 0). attempt(Client, Method, Url, Headers, Body, Attempt) -> #{timeout := Timeout, max_retries := MaxRetries} = Client, Request = case Body of undefined -> {Url, Headers}; _ -> {Url, Headers, "application/json", Body} end, HttpOptions = [{timeout, Timeout}, {connect_timeout, Timeout}, {autoredirect, false}] ++ ssl_options(Url), case httpc:request(Method, Request, HttpOptions, [{body_format, binary}]) of {ok, {{_Version, Status, _Reason}, RespHeaders, RespBody}} when Status >= 200, Status < 300 -> _ = RespHeaders, decode(RespBody); {ok, {{_Version, Status, _Reason}, RespHeaders, RespBody}} -> case should_retry_status(Client, Status) andalso Attempt < MaxRetries of true -> backoff(Client, Attempt, retry_after_ms(RespHeaders)), attempt(Client, Method, Url, Headers, Body, Attempt + 1); false -> {error, parse_error_body(RespBody, Status)} end; {error, _TransportReason} when Attempt < MaxRetries -> %% Transport errors are retried up to max_retries regardless of %% the retry-on-status flags, matching the other Ipregistry %% client libraries. backoff(Client, Attempt, 0), attempt(Client, Method, Url, Headers, Body, Attempt + 1); {error, TransportReason} -> {error, {client_error, {http_error, TransportReason}}} end. should_retry_status(#{retry_on_too_many_requests := Retry429}, 429) -> Retry429; should_retry_status(#{retry_on_server_error := Retry5xx}, Status) when Status >= 500, Status < 600 -> Retry5xx; should_retry_status(_Client, _Status) -> false. %% Waits before the next retry attempt, honoring an explicit Retry-After %% duration when positive and otherwise using exponential backoff. backoff(#{retry_interval := Interval}, Attempt, RetryAfterMs) -> Delay = case RetryAfterMs of Ms when is_integer(Ms), Ms > 0 -> Ms; _ -> Interval bsl min(Attempt, 30) end, timer:sleep(Delay). %% Parses a Retry-After header expressed as an integer number of seconds. %% Returns 0 when the header is absent or invalid (the HTTP-date form is not %% supported, matching the other Ipregistry client libraries). retry_after_ms(Headers) -> case lists:keyfind("retry-after", 1, Headers) of {_, Value} -> case string:to_integer(string:trim(Value)) of {Seconds, ""} when Seconds >= 0 -> Seconds * 1000; _ -> 0 end; false -> 0 end. decode(Body) -> try {ok, json:decode(Body)} catch _:Reason -> {error, {client_error, {decode_error, Reason}}} end. %% Converts a non-2xx response body into an api_error, falling back to a %% generic message when the body is not a recognizable error payload. parse_error_body(Body, Status) -> try json:decode(Body) of #{<<"code">> := Code} = Payload when is_binary(Code) -> {api_error, maps:put(status, Status, to_api_error(Payload))}; _ -> {api_error, generic_api_error(Status)} catch _:_ -> {api_error, generic_api_error(Status)} end. %% @doc Converts a decoded API error payload (a map with binary keys) into %% an {@link ipregistry:api_error()} map. -spec to_api_error(#{binary() => term()}) -> ipregistry:api_error(). to_api_error(Payload) -> #{ code => binary_field(Payload, <<"code">>), message => binary_field(Payload, <<"message">>), resolution => binary_field(Payload, <<"resolution">>) }. binary_field(Payload, Key) -> case Payload of #{Key := Value} when is_binary(Value) -> Value; _ -> <<>> end. generic_api_error(Status) -> #{ code => <<>>, message => iolist_to_binary(["unexpected HTTP status ", integer_to_binary(Status)]), resolution => <<>>, status => Status }. ssl_options("https://" ++ _) -> [ {ssl, [ {verify, verify_peer}, {cacerts, public_key:cacerts_get()}, {depth, 3}, {customize_hostname_check, [ {match_fun, public_key:pkix_verify_hostname_match_fun(https)} ]} ]} ]; ssl_options(_Url) -> [].