mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(session): enforce provider retry modes
This commit is contained in:
@@ -112,15 +112,20 @@ Extensions that depend on canonical gateway/provider error ownership should
|
||||
declare `requiredRuntimeFeatures: ["signedSession@2"]`. Version 2 only mutates
|
||||
gateway session state for the exact `SESSION_INVALID/bootstrap_session` or
|
||||
`VERIFY_REQUIRED/verify` contracts, exposes canonical error fields to JS, and
|
||||
applies bounded `Retry-After` handling to retryable `PROVIDER_UNAVAILABLE`
|
||||
responses. `403 REQUEST_AUTH_INVALID` preserves the session and only retries
|
||||
once when a newer session generation is already available. BYOA
|
||||
keeps provider-owned failures separate. `403 REQUEST_AUTH_INVALID` preserves
|
||||
the session and only retries once when a newer session generation is already
|
||||
available. BYOA
|
||||
reauthentication remains a separate provider action. An active generation that
|
||||
receives canonical `428 VERIFY_REQUIRED` is blocked in the shared coordinator,
|
||||
so later requests join the same verification flow instead of hitting the
|
||||
gateway repeatedly. Every retry is a newly signed request with a fresh
|
||||
timestamp and nonce; `retryable: true` therefore also asserts that any ticket
|
||||
attached to the operation is safe/idempotent for that retry.
|
||||
gateway repeatedly.
|
||||
|
||||
Extensions that depend on provider retry modes should require
|
||||
`signedSession@3`. The runtime exposes `retryMode` and only auto-retries
|
||||
`PROVIDER_UNAVAILABLE` when `retryable: true` is paired with
|
||||
`retry_mode: "same_operation"`. Every such retry is newly signed with a fresh
|
||||
timestamp and nonce. `new_ticket`, `poll_existing`, `none`, missing, and unknown
|
||||
modes are returned to the extension without automatic replay.
|
||||
|
||||
Do not use legacy spellings such as `display_name`, `types`,
|
||||
`permissions.network.domains`, or an object for `permissions.network`.
|
||||
|
||||
@@ -251,7 +251,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
||||
}
|
||||
|
||||
var supportedRuntimeFeatures = map[string]int{
|
||||
"signedSession": 2,
|
||||
"signedSession": 3,
|
||||
"sessionRefresh": 1,
|
||||
"sessionGrant": 1,
|
||||
"globalAction": 1,
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestValidateManifestGates(t *testing.T) {
|
||||
SetAppVersion("4.5.0")
|
||||
|
||||
pass := &ExtensionManifest{
|
||||
RequiredRuntimeFeatures: []string{"signedSession@2", "sessionGrant"},
|
||||
RequiredRuntimeFeatures: []string{"signedSession@3", "sessionGrant"},
|
||||
}
|
||||
if err := validateManifestGates(pass); err != nil {
|
||||
t.Fatalf("supported features should pass: %v", err)
|
||||
|
||||
@@ -143,6 +143,7 @@ type signedSessionErrorContract struct {
|
||||
Origin string `json:"origin,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
RetryMode string `json:"retry_mode,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
}
|
||||
|
||||
@@ -334,6 +335,7 @@ func parseSignedSessionErrorContract(body []byte) (signedSessionErrorContract, b
|
||||
contract.Code = strings.ToUpper(strings.TrimSpace(contract.Code))
|
||||
contract.Origin = strings.ToLower(strings.TrimSpace(contract.Origin))
|
||||
contract.Action = strings.ToLower(strings.TrimSpace(contract.Action))
|
||||
contract.RetryMode = strings.ToLower(strings.TrimSpace(contract.RetryMode))
|
||||
if contract.RetryAfterSeconds < 0 {
|
||||
contract.RetryAfterSeconds = 0
|
||||
}
|
||||
@@ -358,11 +360,12 @@ func signedSessionGatewayAction(statusCode int, contract signedSessionErrorContr
|
||||
}
|
||||
}
|
||||
|
||||
func signedSessionProviderUnavailable(statusCode int, contract signedSessionErrorContract) bool {
|
||||
func signedSessionSameOperationRetry(statusCode int, contract signedSessionErrorContract) bool {
|
||||
return statusCode == http.StatusServiceUnavailable &&
|
||||
contract.Origin == "provider" &&
|
||||
contract.Code == "PROVIDER_UNAVAILABLE" &&
|
||||
contract.Retryable
|
||||
contract.Retryable &&
|
||||
contract.RetryMode == "same_operation"
|
||||
}
|
||||
|
||||
func signedSessionRequestAuthInvalid(statusCode int, contract signedSessionErrorContract) bool {
|
||||
@@ -710,7 +713,7 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
||||
}
|
||||
contract, _ := parseSignedSessionErrorContract(respBody)
|
||||
|
||||
if signedSessionProviderUnavailable(resp.StatusCode, contract) {
|
||||
if signedSessionSameOperationRetry(resp.StatusCode, contract) {
|
||||
if providerRetries >= signedSessionMaxProviderRetries {
|
||||
return r.signedSessionResponseValue(resp, respBody, respHeaders)
|
||||
}
|
||||
@@ -861,6 +864,7 @@ func (r *extensionRuntime) signedSessionResponseValue(
|
||||
result["origin"] = contract.Origin
|
||||
result["action"] = contract.Action
|
||||
result["retryable"] = contract.Retryable
|
||||
result["retryMode"] = contract.RetryMode
|
||||
}
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
@@ -1337,7 +1337,7 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Provider authentication failed","code":"PROVIDER_AUTH_FAILED","origin":"provider","retryable":false}`,
|
||||
`{"error":"Provider authentication failed","code":"PROVIDER_AUTH_FAILED","origin":"provider","retryable":false,"retry_mode":"none"}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
@@ -1352,7 +1352,9 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
|
||||
runtime.vm.ToValue("/tickets"),
|
||||
}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if result["code"] != "PROVIDER_AUTH_FAILED" || result["origin"] != "provider" {
|
||||
if result["code"] != "PROVIDER_AUTH_FAILED" ||
|
||||
result["origin"] != "provider" ||
|
||||
result["retryMode"] != "none" {
|
||||
t.Fatalf("provider contract was not exposed to the extension: %+v", result)
|
||||
}
|
||||
if result["needsVerification"] == true || calls != 1 {
|
||||
@@ -1367,6 +1369,70 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-replay retry modes fail closed", func(t *testing.T) {
|
||||
previousWait := signedSessionProviderWait
|
||||
waitCalls := 0
|
||||
signedSessionProviderWait = func(context.Context, time.Duration) error {
|
||||
waitCalls++
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { signedSessionProviderWait = previousWait })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modeJSON string
|
||||
wantMode string
|
||||
}{
|
||||
{name: "missing mode"},
|
||||
{name: "none", modeJSON: `,"retry_mode":"none"`, wantMode: "none"},
|
||||
{name: "new ticket", modeJSON: `,"retry_mode":"new_ticket"`, wantMode: "new_ticket"},
|
||||
{name: "poll existing", modeJSON: `,"retry_mode":"poll_existing"`, wantMode: "poll_existing"},
|
||||
{name: "unknown", modeJSON: `,"retry_mode":"future_mode"`, wantMode: "future_mode"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
body := fmt.Sprintf(
|
||||
`{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true%s,"retry_after_seconds":10}`,
|
||||
tc.modeJSON,
|
||||
)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Header: http.Header{"Retry-After": []string{"10"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "retry-mode-"+strings.ReplaceAll(tc.name, " ", "-"), transport)
|
||||
config := SignedSessionConfig{Namespace: runtime.extensionID, BaseURL: "https://auth.example.com"}
|
||||
runtime.manifest.SignedSession = &config
|
||||
resolved := saveUsableSignedSession(t, runtime, config, "sess-retry-mode")
|
||||
waitsBefore := waitCalls
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("POST"),
|
||||
runtime.vm.ToValue("/tickets"),
|
||||
}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if calls != 1 || waitCalls != waitsBefore {
|
||||
t.Fatalf("mode %q was replayed: calls=%d waits=%d", tc.wantMode, calls, waitCalls-waitsBefore)
|
||||
}
|
||||
if result["retryMode"] != tc.wantMode {
|
||||
t.Fatalf("retryMode = %v, want %q: %+v", result["retryMode"], tc.wantMode, result)
|
||||
}
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SessionID != "sess-retry-mode" {
|
||||
t.Fatalf("mode %q changed gateway session: %+v", tc.wantMode, reloaded)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("temporary provider failure retries with Retry-After", func(t *testing.T) {
|
||||
previousWait := signedSessionProviderWait
|
||||
previousNow := signedSessionRequestNow
|
||||
@@ -1400,7 +1466,7 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Header: http.Header{"Retry-After": []string{"10"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true,"retry_after_seconds":30}`,
|
||||
`{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true,"retry_mode":"same_operation","retry_after_seconds":30}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
@@ -1466,7 +1532,7 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) {
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Header: http.Header{"Retry-After": []string{"1"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true,"retry_after_seconds":1}`,
|
||||
`{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true,"retry_mode":"same_operation","retry_after_seconds":1}`,
|
||||
)),
|
||||
Request: req,
|
||||
}, nil
|
||||
|
||||
Reference in New Issue
Block a user