fix(gold): switch to a managed VNAppMob key when the env key is rejected

This commit is contained in:
tiennm99 committed 2026-09-30 14:19:30 +07:00
1 parent 72b2b21912
commit 83a5b8135f
2 files changed
+54 -3

No files matched your search

+15 -3
View File
@@ -12,6 +12,7 @@ import (
"os" "os"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/tiennm99/miti99bot/internal/log" "github.com/tiennm99/miti99bot/internal/log"
@@ -40,6 +41,10 @@ type VNAppMobClient struct {
Token string // optional env override (GOLD_VNAPP_API_KEY) Token string // optional env override (GOLD_VNAPP_API_KEY)
cache storage.DocStore[apiKeyCache] // module-scoped typed cache store 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 nowFn func() time.Time
mu sync.Mutex mu sync.Mutex
} }
@@ -92,6 +97,12 @@ func (c *VNAppMobClient) FetchSJCPrice(ctx context.Context) (buy, sell float64,
return 0, 0, err 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") log.Warn("vnappmob_sjc_auth_failed", "status", statusErr.StatusCode, "msg", "refreshing key after auth failure")
if refreshErr := c.refreshKey(ctx); refreshErr != nil { if refreshErr := c.refreshKey(ctx); refreshErr != nil {
return 0, 0, fmt.Errorf("vnappmob: 403 refresh failed: %w", refreshErr) 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 } func (e *httpStatusError) Error() string { return e.msg }
// getKey returns a valid API key, refreshing from the typed cache store or the // getKey returns the env-provided Token when set and not yet rejected. Otherwise it returns the
// remote endpoint when the current key is missing or close to expiry. // 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) { func (c *VNAppMobClient) getKey(ctx context.Context) (string, error) {
if c.Token != "" { if c.Token != "" && !c.tokenRejected.Load() {
return c.Token, nil return c.Token, nil
} }
@@ -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)
}
}