fix(session): enforce provider retry modes

This commit is contained in:
zarzet
2026-07-30 13:31:54 +07:00
parent 113ad7ed9b
commit 66928e259d
5 changed files with 90 additions and 15 deletions
+11 -6
View File
@@ -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`.
+1 -1
View File
@@ -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)
+7 -3
View File
@@ -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)
}
+70 -4
View File
@@ -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