fix(stock): expose ssi quote response errors

This commit is contained in:
2026-06-25 16:12:12 +07:00
parent 5e8f457995
commit 35372d25d7
2 changed files with 17 additions and 3 deletions
+10 -3
View File
@@ -20,6 +20,9 @@ const ssiQueryDefaultURL = "https://iboard-query.ssi.com.vn"
// so a slow upstream cannot starve the Telegram reply budget.
const ssiHTTPTimeout = 3 * time.Second
// ssiErrorBodyLimit keeps upstream diagnostics useful without dumping large responses.
const ssiErrorBodyLimit = 512
// PriceClient is the SSI iBoard stock quote fetcher. Zero value uses the
// default SSI URL + a timeout-bound HTTP client; tests inject HTTP + URL.
type PriceClient struct {
@@ -81,7 +84,7 @@ func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, e
}
price := body.Data.MatchedPrice
if price <= 0 {
return 0, ErrNoPrice
return 0, fmt.Errorf("%w: SSI quote has no matchedPrice for %s", ErrNoPrice, strings.ToUpper(ticker))
}
return price, nil
}
@@ -94,12 +97,15 @@ func (c *PriceClient) FetchPrices(ctx context.Context, tickers []string) (map[st
return map[string]float64{}, nil
}
form := url.Values{}
requested := make([]string, 0, len(tickers))
for _, ticker := range tickers {
ticker = strings.TrimSpace(ticker)
if ticker == "" {
continue
}
ticker = strings.ToUpper(ticker)
form.Add("stocks", ticker)
requested = append(requested, ticker)
}
if len(form) == 0 {
return nil, errors.New("stock: no tickers to fetch")
@@ -124,7 +130,7 @@ func (c *PriceClient) FetchPrices(ctx context.Context, tickers []string) (map[st
out[symbol] = quote.MatchedPrice
}
if len(out) == 0 {
return nil, ErrNoPrice
return nil, fmt.Errorf("%w: SSI batch returned no usable quotes for %s (data_len=%d)", ErrNoPrice, strings.Join(requested, ","), len(body.Data))
}
return out, nil
}
@@ -149,7 +155,8 @@ func (c *PriceClient) doJSON(req *http.Request, dst any) error {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ErrNoPrice
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, ssiErrorBodyLimit))
return fmt.Errorf("%w: SSI status %d body %q", ErrNoPrice, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
return fmt.Errorf("stock: SSI decode: %w", err)
+7
View File
@@ -104,16 +104,23 @@ func TestPriceClient_BatchNoUsableData_ReturnsErrNoPrice(t *testing.T) {
if !errors.Is(err, ErrNoPrice) {
t.Errorf("got %v, want ErrNoPrice", err)
}
if !strings.Contains(err.Error(), "SSI batch returned no usable quotes for NOPE (data_len=1)") {
t.Errorf("error = %q, want batch diagnostic", err.Error())
}
}
func TestPriceClient_4xx_ReturnsErrNoPrice(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("blocked by upstream"))
})
_, err := c.FetchPrice(context.Background(), "BADTICKER")
if !errors.Is(err, ErrNoPrice) {
t.Errorf("got %v, want ErrNoPrice", err)
}
if !strings.Contains(err.Error(), "SSI status 404 body") || !strings.Contains(err.Error(), "blocked by upstream") {
t.Errorf("error = %q, want status/body diagnostic", err.Error())
}
}
func TestPriceClient_NegativeMatchedPrice_ReturnsErrNoPrice(t *testing.T) {