From 66928e259df08a5c0c8116cf6e3b0fea3939c892 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 30 Jul 2026 13:31:54 +0700 Subject: [PATCH] fix(session): enforce provider retry modes --- docs/EXTENSION_DEVELOPMENT.md | 17 +++-- go_backend/extension_manager.go | 2 +- .../extension_manager_supplement_test.go | 2 +- go_backend/extension_signed_session.go | 10 ++- go_backend/extension_signed_session_test.go | 74 ++++++++++++++++++- 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/docs/EXTENSION_DEVELOPMENT.md b/docs/EXTENSION_DEVELOPMENT.md index 7c592769..1d19cfce 100644 --- a/docs/EXTENSION_DEVELOPMENT.md +++ b/docs/EXTENSION_DEVELOPMENT.md @@ -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`. diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index c8fe00c1..d407dcca 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -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, diff --git a/go_backend/extension_manager_supplement_test.go b/go_backend/extension_manager_supplement_test.go index 239c4a32..caae8496 100644 --- a/go_backend/extension_manager_supplement_test.go +++ b/go_backend/extension_manager_supplement_test.go @@ -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) diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index 9e4a3794..90b0e393 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -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) } diff --git a/go_backend/extension_signed_session_test.go b/go_backend/extension_signed_session_test.go index ad28466a..fd9c8b2c 100644 --- a/go_backend/extension_signed_session_test.go +++ b/go_backend/extension_signed_session_test.go @@ -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