Packages

Official Erlang client library for the Ipregistry IP geolocation and threat data API

Current section

Files

Jump to
ipregistry src ipregistry_http.erl
Raw

src/ipregistry_http.erl

%% 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(<<BaseUrl/binary, PathWithQuery/binary>>),
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) ->
[].