Skip to content

Commit 59b5a4d

Browse files
authored
Merge pull request #48 from peek-travel/feature/auto-retry
feat: add automatic retry with exponential backoff on 429 rate limit …
2 parents cd1d6ec + 2ab025d commit 59b5a4d

5 files changed

Lines changed: 97 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919

2020
- Added `PeekAppSDK.Client.query_platform/4` for cross-brand API querying. Accepts `install_id`, HTTP method, URL path, and body params.
2121

22+
## [2026-02-13]
23+
24+
### Added
25+
26+
- `query_peek_pro/4` now automatically retries on 429 rate limit errors with exponential backoff (200ms base, randomized, capped at 2s, up to 5 retries). No API changes required.
27+
2228
## [2026-02-12]
2329

2430
### Breaking

lib/peek_app_sdk/client.ex

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
defmodule PeekAppSDK.Client do
22
require Logger
3+
use Retry
34

45
alias PeekAppSDK.Config
56

@@ -32,6 +33,19 @@ defmodule PeekAppSDK.Client do
3233
@spec query_peek_pro(String.t(), String.t(), map(), atom() | nil) ::
3334
{:ok, map()} | {:error, any()}
3435
def query_peek_pro(install_id, gql_query, gql_variables \\ %{}, config_id \\ nil) do
36+
retry with: 200 |> exponential_backoff() |> randomize() |> cap(2000) |> Stream.take(5), atoms: [:rate_limited] do
37+
case do_query_peek_pro(install_id, gql_query, gql_variables, config_id) do
38+
{:error, 429} -> :rate_limited
39+
result -> result
40+
end
41+
after
42+
result -> result
43+
else
44+
error -> error
45+
end
46+
end
47+
48+
defp do_query_peek_pro(install_id, gql_query, gql_variables, config_id) do
3549
body_params = %{
3650
"query" => gql_query,
3751
"variables" => gql_variables
@@ -51,7 +65,6 @@ defmodule PeekAppSDK.Client do
5165
body: body_params,
5266
headers: headers(install_id, config_id, peek_api_key)
5367
) do
54-
# If we get back an error, fail the whole thing and return the error for now.
5568
{:ok, %Tesla.Env{status: 200, body: %{errors: [_error | _rest] = errors}}} ->
5669
{:error, errors}
5770

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ defmodule PeekAppSDK.MixProject do
6161
{:phoenix, "~> 1.7"},
6262
{:phoenix_live_view, "~> 1.0"},
6363
{:finch, "~> 0.13"},
64+
{:retry, "~> 0.18"},
6465
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
6566

6667
# Test dependencies

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"},
3232
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
3333
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
34+
"retry": {:hex, :retry, "0.19.0", "aeb326d87f62295d950f41e1255fe6f43280a1b390d36e280b7c9b00601ccbc2", [:mix], [], "hexpm", "85ef376aa60007e7bff565c366310966ec1bd38078765a0e7f20ec8a220d02ca"},
3435
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
3536
"tesla": {:hex, :tesla, "1.16.0", "de77d083aea08ebd1982600693ff5d779d68a4bb835d136a0394b08f69714660", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb3bdfc0c6c8a23b4e3d86558e812e3577acff1cb4acb6cfe2da1985a1035b89"},
3637
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},

test/peek_app_sdk/client_test.exs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,4 +313,79 @@ defmodule PeekAppSDK.ClientTest do
313313
assert_raise RuntimeError, fn -> Client.operation_name(query) end
314314
end
315315
end
316+
317+
describe "query_peek_pro/4 retry behavior" do
318+
test "retries on 429 rate limit and succeeds on subsequent attempt" do
319+
install_id = "test_install_id"
320+
query = "query Test { test }"
321+
response_data = %{test: "success"}
322+
323+
call_count = :counters.new(1, [:atomics])
324+
325+
Tesla.Adapter.Finch
326+
|> Mimic.stub(:call, fn _env, _opts ->
327+
count = :counters.get(call_count, 1)
328+
:counters.add(call_count, 1, 1)
329+
330+
if count < 2 do
331+
{:ok, %Tesla.Env{status: 429, body: %{error: "rate limited"}}}
332+
else
333+
{:ok, %Tesla.Env{status: 200, body: %{data: response_data}}}
334+
end
335+
end)
336+
337+
assert {:ok, ^response_data} = Client.query_peek_pro(install_id, query)
338+
assert :counters.get(call_count, 1) == 3
339+
end
340+
341+
test "returns error after exhausting all retry attempts on persistent 429" do
342+
install_id = "test_install_id"
343+
query = "query Test { test }"
344+
345+
call_count = :counters.new(1, [:atomics])
346+
347+
Tesla.Adapter.Finch
348+
|> Mimic.stub(:call, fn _env, _opts ->
349+
:counters.add(call_count, 1, 1)
350+
{:ok, %Tesla.Env{status: 429, body: %{error: "rate limited"}}}
351+
end)
352+
353+
assert :rate_limited = Client.query_peek_pro(install_id, query)
354+
# 1 initial attempt + 5 retries = 6 total calls
355+
assert :counters.get(call_count, 1) == 6
356+
end
357+
358+
test "does not retry on non-429 errors" do
359+
install_id = "test_install_id"
360+
query = "query Test { test }"
361+
362+
call_count = :counters.new(1, [:atomics])
363+
364+
Tesla.Adapter.Finch
365+
|> Mimic.stub(:call, fn _env, _opts ->
366+
:counters.add(call_count, 1, 1)
367+
{:ok, %Tesla.Env{status: 500, body: %{error: "server error"}}}
368+
end)
369+
370+
assert {:error, 500} = Client.query_peek_pro(install_id, query)
371+
assert :counters.get(call_count, 1) == 1
372+
end
373+
374+
test "does not retry on GraphQL errors" do
375+
install_id = "test_install_id"
376+
query = "query Test { test }"
377+
errors = [%{message: "Invalid query"}]
378+
379+
call_count = :counters.new(1, [:atomics])
380+
381+
Tesla.Adapter.Finch
382+
|> Mimic.stub(:call, fn _env, _opts ->
383+
:counters.add(call_count, 1, 1)
384+
{:ok, %Tesla.Env{status: 200, body: %{errors: errors}}}
385+
end)
386+
387+
assert {:error, ^errors} = Client.query_peek_pro(install_id, query)
388+
assert :counters.get(call_count, 1) == 1
389+
end
390+
end
316391
end

0 commit comments

Comments
 (0)