feat(gold): add multi-provider gold price fallback chain

Replace goldprice.org (403s datacenter IPs) with a provider chain:
gold-api.com -> Swissquote public quotes -> NBP daily fixing (PLN/gram
converted via the shared FX table). Per-provider failures are logged;
a full-chain outage surfaces as a retryable error instead of the
silent no-price reply.
This commit is contained in:
2026-06-11 22:11:30 +07:00
parent 2097a18cb2
commit 0e0e3b91fa
3 changed files with 266 additions and 83 deletions
+134
View File
@@ -0,0 +1,134 @@
package gold
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/tiennm99/miti99bot/internal/log"
)
// XAU/USD provider defaults. All three are free, keyless, and verified to
// answer datacenter IPs (goldprice.org was dropped: it 403s AWS/cloud IPs).
const (
goldAPIDefaultURL = "https://api.gold-api.com/price/XAU"
swissquoteDefaultURL = "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/XAU/USD"
nbpDefaultURL = "https://api.nbp.pl/api/cenyzlota?format=json"
)
type xauProvider struct {
name string
url string
fetch func(ctx context.Context, url string) (float64, error)
}
func (c *GoldPriceClient) providers() []xauProvider {
pick := func(override, def string) string {
if s := strings.TrimSpace(override); s != "" {
return s
}
return def
}
return []xauProvider{
{name: "gold-api.com", url: pick(c.GoldURL, goldAPIDefaultURL), fetch: c.fetchGoldAPI},
{name: "swissquote", url: pick(c.SwissquoteURL, swissquoteDefaultURL), fetch: c.fetchSwissquote},
{name: "nbp", url: pick(c.NBPURL, nbpDefaultURL), fetch: c.fetchNBP},
}
}
// fetchXAUUSD walks the provider chain and returns the first USD/oz price.
// Per-provider failures are logged; if every provider fails the joined error
// deliberately does NOT wrap ErrNoGoldPrice so callers treat it as a
// retryable fetch failure, not an empty-data reply.
func (c *GoldPriceClient) fetchXAUUSD(ctx context.Context) (float64, error) {
var failures []string
for _, p := range c.providers() {
price, err := p.fetch(ctx, p.url)
if err == nil {
return price, nil
}
log.Warn("gold_price_provider_failed", "provider", p.name, "err", err)
failures = append(failures, fmt.Sprintf("%s: %v", p.name, err))
}
return 0, fmt.Errorf("gold: all price providers failed: %s", strings.Join(failures, "; "))
}
func (c *GoldPriceClient) providerGet(ctx context.Context, url string, dst any) error {
if err := validateEndpoint(url); err != nil {
return err
}
resp, err := c.getJSON(ctx, url)
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
}
// fetchGoldAPI parses gold-api.com: {"price": 4073.6, "currency": "USD"}.
func (c *GoldPriceClient) fetchGoldAPI(ctx context.Context, url string) (float64, error) {
var body struct {
Currency string `json:"currency"`
Price float64 `json:"price"`
}
if err := c.providerGet(ctx, url, &body); err != nil {
return 0, err
}
if body.Currency != "" && body.Currency != "USD" {
return 0, fmt.Errorf("unexpected currency %q", body.Currency)
}
if body.Price <= 0 {
return 0, ErrNoGoldPrice
}
return body.Price, nil
}
// fetchSwissquote parses the public best-bid/offer feed:
// [{"spreadProfilePrices":[{"bid":4074.41,"ask":4075.10}, ...]}, ...]
// and returns the mid price of the first quoted profile.
func (c *GoldPriceClient) fetchSwissquote(ctx context.Context, url string) (float64, error) {
var body []struct {
SpreadProfilePrices []struct {
Bid float64 `json:"bid"`
Ask float64 `json:"ask"`
} `json:"spreadProfilePrices"`
}
if err := c.providerGet(ctx, url, &body); err != nil {
return 0, err
}
for _, platform := range body {
for _, q := range platform.SpreadProfilePrices {
if q.Bid > 0 && q.Ask > 0 {
return (q.Bid + q.Ask) / 2, nil
}
}
}
return 0, ErrNoGoldPrice
}
// fetchNBP parses the Polish central bank daily fixing
// [{"data":"2026-06-11","cena":492.71}] (PLN per gram) and converts to USD/oz
// using the shared FX table. Daily granularity — last-resort fallback only.
func (c *GoldPriceClient) fetchNBP(ctx context.Context, url string) (float64, error) {
var body []struct {
PLNPerGram float64 `json:"cena"`
}
if err := c.providerGet(ctx, url, &body); err != nil {
return 0, err
}
if len(body) == 0 || body[0].PLNPerGram <= 0 {
return 0, ErrNoGoldPrice
}
plnPerUSD, err := c.fetchFXRate(ctx, "PLN")
if err != nil {
return 0, fmt.Errorf("PLN rate: %w", err)
}
return body[0].PLNPerGram * gramsPerTroyOunce / plnPerUSD, nil
}
+18 -58
View File
@@ -14,7 +14,6 @@ import (
)
const (
goldDefaultURL = "https://data-asg.goldprice.org/dbXRates/USD"
fxDefaultURL = "https://open.er-api.com/v6/latest/USD"
goldHTTPTimeout = 10 * time.Second
fxFallbackCacheTTL = time.Hour
@@ -30,17 +29,23 @@ type GoldPrice struct {
VNDPerLuong float64
}
// GoldPriceClient fetches XAU/USD through a chain of free providers (see
// price_providers.go) and converts to VND via a cached USD FX-rate table.
type GoldPriceClient struct {
HTTP *http.Client
GoldURL string
FXURL string
HTTP *http.Client
// Per-provider URL overrides; empty means the provider default.
GoldURL string // primary: gold-api.com
SwissquoteURL string
NBPURL string
FXURL string
defaultOnce sync.Once
defaultClient *http.Client
nowFn func() time.Time
mu sync.Mutex
fxRate float64
fxRates map[string]float64
fxExpiry time.Time
}
@@ -56,7 +61,7 @@ func (c *GoldPriceClient) FetchPrice(ctx context.Context) (GoldPrice, error) {
if err != nil {
return GoldPrice{}, err
}
usdToVND, err := c.fetchUSDVND(ctx)
usdToVND, err := c.fetchFXRate(ctx, "VND")
if err != nil {
return GoldPrice{}, err
}
@@ -92,13 +97,6 @@ func (c *GoldPriceClient) now() time.Time {
return time.Now()
}
func (c *GoldPriceClient) goldURL() string {
if strings.TrimSpace(c.GoldURL) != "" {
return strings.TrimSpace(c.GoldURL)
}
return goldDefaultURL
}
func (c *GoldPriceClient) fxURL() string {
if strings.TrimSpace(c.FXURL) != "" {
return strings.TrimSpace(c.FXURL)
@@ -106,56 +104,18 @@ func (c *GoldPriceClient) fxURL() string {
return fxDefaultURL
}
type goldResponse struct {
Items []goldItem `json:"items"`
}
type goldItem struct {
Currency string `json:"curr"`
XAUPrice float64 `json:"xauPrice"`
}
type fxResponse struct {
Result string `json:"result"`
Rates map[string]float64 `json:"rates"`
TimeNextUpdateUnix int64 `json:"time_next_update_unix"`
}
func (c *GoldPriceClient) fetchXAUUSD(ctx context.Context) (float64, error) {
endpoint := c.goldURL()
if err := validateEndpoint(endpoint); err != nil {
return 0, err
}
resp, err := c.getJSON(ctx, endpoint)
if err != nil {
return 0, fmt.Errorf("gold: GoldPrice request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 0, ErrNoGoldPrice
}
var body goldResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return 0, fmt.Errorf("gold: GoldPrice decode: %w", err)
}
if len(body.Items) == 0 {
return 0, ErrNoGoldPrice
}
item := body.Items[0]
if item.Currency != "" && item.Currency != "USD" {
return 0, ErrNoGoldPrice
}
if item.XAUPrice <= 0 {
return 0, ErrNoGoldPrice
}
return item.XAUPrice, nil
}
func (c *GoldPriceClient) fetchUSDVND(ctx context.Context) (float64, error) {
// fetchFXRate returns the USD→code rate from a cached full rate table so one
// FX call serves both the VND conversion and the NBP fallback (PLN).
func (c *GoldPriceClient) fetchFXRate(ctx context.Context, code string) (float64, error) {
c.mu.Lock()
now := c.now()
if c.fxRate > 0 && now.Before(c.fxExpiry) {
rate := c.fxRate
if rate := c.fxRates[code]; rate > 0 && now.Before(c.fxExpiry) {
c.mu.Unlock()
return rate, nil
}
@@ -174,7 +134,7 @@ func (c *GoldPriceClient) fetchUSDVND(ctx context.Context) (float64, error) {
return 0, errors.New("gold: FX rate limited")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 0, ErrNoGoldPrice
return 0, fmt.Errorf("gold: FX status %d: %w", resp.StatusCode, ErrNoGoldPrice)
}
var body fxResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
@@ -183,7 +143,7 @@ func (c *GoldPriceClient) fetchUSDVND(ctx context.Context) (float64, error) {
if body.Result != "" && body.Result != "success" {
return 0, ErrNoGoldPrice
}
rate := body.Rates["VND"]
rate := body.Rates[code]
if rate <= 0 {
return 0, ErrNoGoldPrice
}
@@ -192,7 +152,7 @@ func (c *GoldPriceClient) fetchUSDVND(ctx context.Context) (float64, error) {
expiry = time.Unix(body.TimeNextUpdateUnix, 0)
}
c.mu.Lock()
c.fxRate = rate
c.fxRates = body.Rates
c.fxExpiry = expiry
c.mu.Unlock()
return rate, nil
+114 -25
View File
@@ -11,13 +11,24 @@ import (
"time"
)
// newChainTestClient pins every provider URL to the test server so no test
// ever falls through to a real network endpoint.
func newChainTestClient(srv *httptest.Server) *GoldPriceClient {
return &GoldPriceClient{
GoldURL: srv.URL + "/gold",
SwissquoteURL: srv.URL + "/swissquote",
NBPURL: srv.URL + "/nbp",
FXURL: srv.URL + "/fx",
}
}
func TestGoldPriceClient_FetchLuongPrice(t *testing.T) {
now := time.Unix(100, 0)
var fxHits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/gold":
_, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":2000}]}`))
_, _ = w.Write([]byte(`{"currency":"USD","price":2000}`))
case "/fx":
atomic.AddInt32(&fxHits, 1)
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000},"time_next_update_unix":1000}`))
@@ -26,7 +37,8 @@ func TestGoldPriceClient_FetchLuongPrice(t *testing.T) {
}
}))
defer srv.Close()
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx", nowFn: func() time.Time { return now }}
c := newChainTestClient(srv)
c.nowFn = func() time.Time { return now }
got, err := c.FetchLuongPrice(context.Background())
if err != nil {
@@ -44,46 +56,125 @@ func TestGoldPriceClient_FetchLuongPrice(t *testing.T) {
}
}
func TestGoldPriceClient_InvalidResponses(t *testing.T) {
func TestGoldPriceClient_FallbackToSwissquote(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/gold":
w.WriteHeader(http.StatusForbidden) // primary blocked, like goldprice.org was
case "/swissquote":
_, _ = w.Write([]byte(`[{"spreadProfilePrices":[{"bid":4000,"ask":4010}]}]`))
case "/fx":
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000}}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
p, err := newChainTestClient(srv).FetchPrice(context.Background())
if err != nil {
t.Fatalf("FetchPrice: %v", err)
}
if p.XAUUSD != 4005 { // mid of bid/ask
t.Errorf("XAUUSD: got %v, want 4005", p.XAUUSD)
}
}
func TestGoldPriceClient_FallbackToNBP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/gold", "/swissquote":
w.WriteHeader(http.StatusInternalServerError)
case "/nbp":
_, _ = w.Write([]byte(`[{"data":"2026-06-11","cena":500}]`))
case "/fx":
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000,"PLN":4}}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
p, err := newChainTestClient(srv).FetchPrice(context.Background())
if err != nil {
t.Fatalf("FetchPrice: %v", err)
}
want := 500 * gramsPerTroyOunce / 4 // PLN/gram → USD/oz via USD→PLN rate
if math.Abs(p.XAUUSD-want) > 0.01 {
t.Errorf("XAUUSD: got %v, want %v", p.XAUUSD, want)
}
}
func TestGoldPriceClient_AllProvidersFailIsRetryable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
_, err := newChainTestClient(srv).FetchPrice(context.Background())
if err == nil {
t.Fatal("want error when every provider fails")
}
// A full-chain outage is a fetch failure, not a benign "no data" reply.
if errors.Is(err, ErrNoGoldPrice) {
t.Fatalf("got ErrNoGoldPrice, want retryable error: %v", err)
}
}
func TestGoldPriceClient_InvalidProviderResponses(t *testing.T) {
cases := []struct {
name string
gold string
fx string
name string
gold string
swissquote string
nbp string
}{
{name: "missing gold", gold: `{"items":[]}`, fx: `{"result":"success","rates":{"VND":25000}}`},
{name: "wrong currency", gold: `{"items":[{"curr":"EUR","xauPrice":2000}]}`, fx: `{"result":"success","rates":{"VND":25000}}`},
{name: "missing fx", gold: `{"items":[{"curr":"USD","xauPrice":2000}]}`, fx: `{"result":"success","rates":{}}`},
{name: "empty bodies", gold: `{}`, swissquote: `[]`, nbp: `[]`},
{name: "wrong currency and zero quotes", gold: `{"currency":"EUR","price":2000}`, swissquote: `[{"spreadProfilePrices":[{"bid":0,"ask":0}]}]`, nbp: `[{"cena":0}]`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/gold" {
switch r.URL.Path {
case "/gold":
_, _ = w.Write([]byte(tc.gold))
return
case "/swissquote":
_, _ = w.Write([]byte(tc.swissquote))
case "/nbp":
_, _ = w.Write([]byte(tc.nbp))
case "/fx":
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000,"PLN":4}}`))
}
_, _ = w.Write([]byte(tc.fx))
}))
defer srv.Close()
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"}
_, err := c.FetchLuongPrice(context.Background())
if !errors.Is(err, ErrNoGoldPrice) {
t.Errorf("got %v, want ErrNoGoldPrice", err)
if _, err := newChainTestClient(srv).FetchPrice(context.Background()); err == nil {
t.Fatal("want error for invalid provider data")
}
})
}
}
func TestGoldPriceClient_MissingFXRateReturnsNoPrice(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/gold" {
_, _ = w.Write([]byte(`{"currency":"USD","price":2000}`))
return
}
_, _ = w.Write([]byte(`{"result":"success","rates":{}}`))
}))
defer srv.Close()
_, err := newChainTestClient(srv).FetchPrice(context.Background())
if !errors.Is(err, ErrNoGoldPrice) {
t.Fatalf("got %v, want ErrNoGoldPrice", err)
}
}
func TestGoldPriceClient_OverflowPriceReturnsNoPrice(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/gold" {
_, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":1e308}]}`))
_, _ = w.Write([]byte(`{"currency":"USD","price":1e308}`))
return
}
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":1e308}}`))
}))
defer srv.Close()
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"}
_, err := c.FetchLuongPrice(context.Background())
_, err := newChainTestClient(srv).FetchLuongPrice(context.Background())
if !errors.Is(err, ErrNoGoldPrice) {
t.Fatalf("got %v, want ErrNoGoldPrice", err)
}
@@ -92,14 +183,13 @@ func TestGoldPriceClient_OverflowPriceReturnsNoPrice(t *testing.T) {
func TestGoldPriceClient_FXRateLimitedIsRetryable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/gold" {
_, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":2000}]}`))
_, _ = w.Write([]byte(`{"currency":"USD","price":2000}`))
return
}
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"}
_, err := c.FetchLuongPrice(context.Background())
_, err := newChainTestClient(srv).FetchLuongPrice(context.Background())
if err == nil || errors.Is(err, ErrNoGoldPrice) {
t.Fatalf("got %v, want retryable non-ErrNoGoldPrice", err)
}
@@ -109,7 +199,7 @@ func TestGoldPriceClient_FetchPrice(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/gold":
_, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":3000}]}`))
_, _ = w.Write([]byte(`{"currency":"USD","price":3000}`))
case "/fx":
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000}}`))
default:
@@ -117,8 +207,7 @@ func TestGoldPriceClient_FetchPrice(t *testing.T) {
}
}))
defer srv.Close()
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"}
p, err := c.FetchPrice(context.Background())
p, err := newChainTestClient(srv).FetchPrice(context.Background())
if err != nil {
t.Fatalf("FetchPrice: %v", err)
}