diff --git a/internal/modules/gold/vnappmob_client.go b/internal/modules/gold/vnappmob_client.go index bdb2350..f7d730d 100644 --- a/internal/modules/gold/vnappmob_client.go +++ b/internal/modules/gold/vnappmob_client.go @@ -12,6 +12,7 @@ import ( "os" "strings" "sync" + "sync/atomic" "time" "github.com/tiennm99/miti99bot/internal/log" @@ -40,6 +41,10 @@ type VNAppMobClient struct { Token string // optional env override (GOLD_VNAPP_API_KEY) cache storage.DocStore[apiKeyCache] // module-scoped typed cache store + // tokenRejected is set once VNAppMob answers 401/403 to Token, so later + // calls use the self-managed key instead of the dead env override. + tokenRejected atomic.Bool + nowFn func() time.Time mu sync.Mutex } @@ -92,6 +97,12 @@ func (c *VNAppMobClient) FetchSJCPrice(ctx context.Context) (buy, sell float64, return 0, 0, err } + if c.Token != "" && key == c.Token { + // Without this the retry below would get the same rejected env key back + // from getKey, and an expired GOLD_VNAPP_API_KEY could never recover. + c.tokenRejected.Store(true) + log.Warn("vnappmob_env_key_rejected", "status", statusErr.StatusCode, "msg", "GOLD_VNAPP_API_KEY rejected; switching to self-managed key") + } log.Warn("vnappmob_sjc_auth_failed", "status", statusErr.StatusCode, "msg", "refreshing key after auth failure") if refreshErr := c.refreshKey(ctx); refreshErr != nil { return 0, 0, fmt.Errorf("vnappmob: 403 refresh failed: %w", refreshErr) @@ -165,10 +176,11 @@ type httpStatusError struct { func (e *httpStatusError) Error() string { return e.msg } -// getKey returns a valid API key, refreshing from the typed cache store or the -// remote endpoint when the current key is missing or close to expiry. +// getKey returns the env-provided Token when set and not yet rejected. Otherwise it returns the +// cached key, requesting a new one from the remote endpoint when the cached key +// is missing or close to expiry. func (c *VNAppMobClient) getKey(ctx context.Context) (string, error) { - if c.Token != "" { + if c.Token != "" && !c.tokenRejected.Load() { return c.Token, nil } diff --git a/internal/modules/gold/vnappmob_client_test.go b/internal/modules/gold/vnappmob_client_test.go index 1ca5c5c..fea7f8c 100644 --- a/internal/modules/gold/vnappmob_client_test.go +++ b/internal/modules/gold/vnappmob_client_test.go @@ -303,3 +303,42 @@ func TestFetchSJCPrice_InvalidValues(t *testing.T) { }) } } + +// A rejected GOLD_VNAPP_API_KEY must not be retried: the client switches to a +// self-managed key for this call and every later one. +func TestFetchSJCPrice_RejectedEnvTokenSwitchesToManagedKey(t *testing.T) { + exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix() + managed := makeJWT(exp) + var envHits, refreshHits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/request_api_key": + atomic.AddInt32(&refreshHits, 1) + fmt.Fprint(w, managed) + case "/api/v2/gold/sjc": + if r.Header.Get("Authorization") != "Bearer "+managed { + atomic.AddInt32(&envHits, 1) + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"results":[{"buy_1l":"90000000.0","sell_1l":"91000000.0"}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c := newTestVNAppMobClient(srv, newTestColl()) + c.Token = "expired-env-key" + for i := range 2 { + if _, _, err := c.FetchSJCPrice(context.Background()); err != nil { + t.Fatalf("FetchSJCPrice call %d: %v", i+1, err) + } + } + if got := atomic.LoadInt32(&envHits); got != 1 { + t.Fatalf("requests with env key: got %d, want 1", got) + } + if got := atomic.LoadInt32(&refreshHits); got != 1 { + t.Fatalf("refresh hits: got %d, want 1", got) + } +}