Skip to content

Commit

Permalink
Added global and per-request retries of failed requests (#56)
Browse files Browse the repository at this point in the history
* Simplified API calls

* Added support for retries

* Update README.md

* Review fix

* Added cancellation tokens

* Update CHANGELOG.md
  • Loading branch information
britzl authored Aug 30, 2022
1 parent 38b772c commit d341e59
Show file tree
Hide file tree
Showing 8 changed files with 3,047 additions and 3,455 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- Added global and per-request retries of failed requests
- Added cancellation token for Rest API requests

## [3.0.3] - 2022-05-20
### Fixed
Expand Down
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,55 @@ end)
```


### Retries
Nakama has a global and per-request retry configuration to control how failed API calls are retried.

```lua
local retries = require "nakama.util.retries"

-- use a global retry policy with 5 attempts with 1 second intervals
local config = {
host = "127.0.0.1",
port = 7350,
username = "defaultkey",
password = "",
retry_policy = retries.fixed(5, 1),
engine = defold,
}
local client = nakama.create_client(config)

-- use a retry policy specifically for this request
-- 5 retries at intervals increasing by 1 second between attempts (eg 1s, 2s, 3s, 4s, 5s)
nakama.list_friends(client, 10, 0, "", retries.incremental(5, 1))
```


### Cancelling requests
Create a cancellation token and pass that with a request to cancel the request before it has completed.

```lua
-- use a global retry policy with 5 attempts with 1 second intervals
local config = {
host = "127.0.0.1",
port = 7350,
username = "defaultkey",
password = "",
retry_policy = retries.fixed(5, 1),
engine = defold,
}
local client = nakama.create_client(config)

-- create a cancellation token
local token = nakama.cancellation_token()

-- start a request and proivide the cancellation token
nakama.list_friends(client, 10, 0, "", nil, callback, token)

-- immediately cancel the request without waiting for the request callback to be invoked
nakama.cancel(token)
```


### Socket

You can connect to the server over a realtime WebSocket connection to send and receive chat messages, get notifications, and matchmake into a multiplayer match.
Expand Down Expand Up @@ -241,12 +290,13 @@ local client = nakama.create_client(config)

The engine module must provide the following functions:

* `http(config, url_path, query_params, method, post_data, callback)` - Make HTTP request.
* `http(config, url_path, query_params, method, post_data, cancellation_token, callback)` - Make HTTP request.
* `config` - Config table passed to `nakama.create()`
* `url_path` - Path to append to the base uri
* `query_params` - Key-value pairs to use as URL query parameters
* `method` - "GET", "POST"
* `post_data` - Data to post
* `cancellation_token` - Check if `cancellation_token.cancelled` is true
* `callback` - Function to call with result (response)

* `socket_create(config, on_message)` - Create socket. Must return socket instance (table with engine specific socket state).
Expand Down
2 changes: 1 addition & 1 deletion codegen/realtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ def events_to_lua(rtapi, api):
generated_lua = SOCKET_LUA % (messages["lua"], "\n-- ".join(events["ids"]), events["lua"])

if out_path:
with open(out_path, "wb") as f:
with open(out_path, "w") as f:
f.write(generated_lua)
else:
print(generated_lua)
Expand Down
120 changes: 89 additions & 31 deletions codegen/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ local json = require "nakama.util.json"
local b64 = require "nakama.util.b64"
local log = require "nakama.util.log"
local async = require "nakama.util.async"
local retries = require "nakama.util.retries"
local api_session = require "nakama.session"
local socket = require "nakama.socket"
Expand Down Expand Up @@ -103,6 +104,7 @@ function M.create_client(config)
client.config.password = config.password
client.config.timeout = config.timeout or 10
client.config.use_ssl = config.use_ssl
client.config.retry_policy = config.retry_policy or retries.none()
local ignored_fns = { create_client = true, sync = true }
for name,fn in pairs(M) do
Expand Down Expand Up @@ -132,19 +134,86 @@ function M.set_bearer_token(client, bearer_token)
client.config.bearer_token = bearer_token
end
-- cancellation tokens associated with a coroutine
local cancellation_tokens = {}
-- cancel a cancellation token
function M.cancel(token)
assert(token)
token.cancelled = true
end
-- create a cancellation token
-- use this to cancel an ongoing API call or a sequence of API calls
-- @return token Pass the token to a call to nakama.sync() or to any of the API calls
function M.cancellation_token()
local token = {
cancelled = false
}
function token.cancel()
token.cancelled = true
end
return token
end
-- Private
function M.sync(fn)
local co = coroutine.create(fn)
-- Run code within a coroutine
-- @param fn The code to run
-- @param cancellation_token Optional cancellation token to cancel the running code
function M.sync(fn, cancellation_token)
assert(fn)
local co = nil
co = coroutine.create(function()
cancellation_tokens[co] = cancellation_token
fn()
cancellation_tokens[co] = nil
end)
local ok, err = coroutine.resume(co)
if not ok then
log(err)
cancellation_tokens[co] = nil
end
end
--
-- Nakama REST API
--
-- http request helper used to reduce code duplication in all API functions below
local function http(client, callback, url_path, query_params, method, post_data, retry_policy, cancellation_token, handler_fn)
if callback then
log(url_path, "with callback")
client.engine.http(client.config, url_path, query_params, method, post_data, retry_policy, cancellation_token, function(result)
if not cancellation_token or not cancellation_token.cancelled then
callback(handler_fn(result))
end
end)
else
log(url_path, "with coroutine")
local co = coroutine.running()
assert(co, "You must be running this from withing a coroutine")
-- get cancellation token associated with this coroutine
cancellation_token = cancellation_tokens[co]
if cancellation_token and cancellation_token.cancelled then
cancellation_tokens[co] = nil
return
end
return async(function(done)
client.engine.http(client.config, url_path, query_params, method, post_data, retry_policy, cancellation_token, function(result)
if cancellation_token and cancellation_token.cancelled then
cancellation_tokens[co] = nil
return
end
done(handler_fn(result))
end)
end)
end
end
{{- range $url, $path := .Paths }}
{{- range $method, $operation := $path}}
Expand All @@ -167,8 +236,10 @@ end
{{- end }}
{{- end }}
-- @param callback Optional callback function.
-- A coroutine is used and the result returned if no function is provided.
-- @param callback Optional callback function
-- A coroutine is used and the result is returned if no callback function is provided.
-- @param retry_policy Optional retry policy used specifically for this call or nil
-- @param cancellation_token Optional cancellation token for this call
-- @return The result.
function M.{{ $operation.OperationId | pascalToSnake | removePrefix }}(client
{{- range $i, $parameter := $operation.Parameters }}
Expand All @@ -181,7 +252,7 @@ function M.{{ $operation.OperationId | pascalToSnake | removePrefix }}(client
{{- end }}
{{- if and (eq $parameter.Name "body") $parameter.Schema.Type }}, {{ $parameter.Name }} {{- end }}
{{- if ne $parameter.Name "body" }}, {{ $varName }} {{- end }}
{{- end }}, callback)
{{- end }}, callback, retry_policy, cancellation_token)
assert(client, "You must provide a client")
{{- range $parameter := $operation.Parameters }}
{{- $varName := varName $parameter.Name $parameter.Type $parameter.Schema.Ref }}
Expand Down Expand Up @@ -216,41 +287,28 @@ function M.{{ $operation.OperationId | pascalToSnake | removePrefix }}(client
{{- end}}
{{- end}}
local post_data = nil
{{- range $parameter := $operation.Parameters }}
{{- $varName := varName $parameter.Name $parameter.Type $parameter.Schema.Ref }}
{{- if eq $parameter.In "body" }}
{{- if $parameter.Schema.Ref }}
local post_data = json.encode({
post_data = json.encode({
{{- bodyFunctionArgsTable $parameter.Schema.Ref}} })
{{- end }}
{{- if $parameter.Schema.Type }}
local post_data = json.encode(body)
post_data = json.encode(body)
{{- end }}
{{- end }}
{{- end }}
{{- end }}
if callback then
log("{{ $operation.OperationId | pascalToSnake | removePrefix }}() with callback")
client.engine.http(client.config, url_path, query_params, "{{- $method | uppercase }}", post_data, function(result)
{{- if $operation.Responses.Ok.Schema.Ref }}
if not result.error and {{ $operation.Responses.Ok.Schema.Ref | cleanRef | pascalToSnake }} then
result = {{ $operation.Responses.Ok.Schema.Ref | cleanRef | pascalToSnake }}.create(result)
end
{{- end }}
callback(result)
end)
else
log("{{ $operation.OperationId | pascalToSnake | removePrefix }}() with coroutine")
return async(function(done)
client.engine.http(client.config, url_path, query_params, "{{- $method | uppercase }}", post_data, function(result)
{{- if $operation.Responses.Ok.Schema.Ref }}
if not result.error and {{ $operation.Responses.Ok.Schema.Ref | cleanRef | pascalToSnake }} then
result = {{ $operation.Responses.Ok.Schema.Ref | cleanRef | pascalToSnake }}.create(result)
end
{{- end }}
done(result)
end)
end)
end
return http(client, callback, url_path, query_params, "{{- $method | uppercase }}", post_data, retry_policy, cancellation_token, function(result)
{{- if $operation.Responses.Ok.Schema.Ref }}
if not result.error and {{ $operation.Responses.Ok.Schema.Ref | cleanRef | pascalToSnake }} then
result = {{ $operation.Responses.Ok.Schema.Ref | cleanRef | pascalToSnake }}.create(result)
end
{{- end }}
return result
end)
end
{{- end }}
{{- end }}
Expand Down
2 changes: 2 additions & 0 deletions example/example.script
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
local nakama = require "nakama.nakama"
local log = require "nakama.util.log"
local retries = require "nakama.util.retries"
local defold = require "nakama.engine.defold"


Expand Down Expand Up @@ -37,6 +38,7 @@ function init(self)
port = 7350,
username = "defaultkey",
password = "",
retry_policy = retries.incremental(5, 1),
engine = defold,
}
local client = nakama.create_client(config)
Expand Down
58 changes: 45 additions & 13 deletions nakama/engine/defold.lua
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,49 @@ function M.uuid()
return uuid(mac)
end


local make_http_request
make_http_request = function(url, method, callback, headers, post_data, options, retry_intervals, retry_count, cancellation_token)
if cancellation_token and cancellation_token.cancelled then
callback(nil)
return
end
http.request(url, method, function(self, id, result)
if cancellation_token and cancellation_token.cancelled then
callback(nil)
return
end
log(result.response)
local ok, decoded = pcall(json.decode, result.response)
-- return result if everything is ok
if ok and result.status >= 200 and result.status <= 299 then
result.response = decoded
callback(result.response)
return
end

-- return the error if there are no more retries
if retry_count > #retry_intervals then
if not ok then
result.response = { error = true, message = "Unable to decode response" }
else
result.response = { error = decoded.error or true, message = decoded.message, code = decoded.code }
end
callback(result.response)
return
end

-- retry!
local retry_interval = retry_intervals[retry_count]
timer.delay(retry_interval, false, function()
make_http_request(url, method, callback, headers, post_data, options, retry_intervals, retry_count + 1, cancellation_token)
end)
end, headers, post_data, options)

end



--- Make a HTTP request.
-- @param config The http config table, see Defold docs.
-- @param url_path The request URL.
Expand All @@ -51,7 +94,7 @@ end
-- @param post_data String of post data.
-- @param callback The callback function.
-- @return The mac address string.
function M.http(config, url_path, query_params, method, post_data, callback)
function M.http(config, url_path, query_params, method, post_data, retry_policy, cancellation_token, callback)
local query_string = ""
if next(query_params) then
for query_key,query_value in pairs(query_params) do
Expand Down Expand Up @@ -82,18 +125,7 @@ function M.http(config, url_path, query_params, method, post_data, callback)

log("HTTP", method, url)
log("DATA", post_data)
http.request(url, method, function(self, id, result)
log(result.response)
local ok, decoded = pcall(json.decode, result.response)
if not ok then
result.response = { error = true, message = "Unable to decode response" }
elseif result.status < 200 or result.status > 299 then
result.response = { error = decoded.error or true, message = decoded.message, code = decoded.code }
else
result.response = decoded
end
callback(result.response)
end, headers, post_data, options)
make_http_request(url, method, callback, headers, post_data, options, retry_policy or config.retry_policy, 1, cancellation_token)
end

--- Create a new socket with message handler.
Expand Down
Loading

0 comments on commit d341e59

Please sign in to comment.