HH-683: propagate subscription fetch failures (#1)
Build and Publish Docker Image / build-and-push (push) Failing after 9m58s

This commit was merged in pull request #1.
This commit is contained in:
2026-08-26 14:18:53 +08:00
parent c73c2b55cd
commit 9a4e60cf10
4 changed files with 119 additions and 43 deletions
+5 -2
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/gofiber/fiber/v3"
"github.com/sirupsen/logrus"
"github.com/peterqiu0516/sub-store/internal/middleware"
"github.com/peterqiu0516/sub-store/internal/model"
@@ -55,7 +56,8 @@ func (d *Deps) HandleDownloadCollection(c fiber.Ctx) error {
ProxyURL: d.Cfg.Fetcher.ProxyURL,
})
if err != nil {
return failed(c, err.Error(), 500)
logrus.WithError(err).Error("Failed to build subscription")
return failed(c, "Failed to build subscription", 500)
}
return d.sendDownloadResponse(c, result, target)
}
@@ -86,7 +88,8 @@ func (d *Deps) HandleDownloadSource(c fiber.Ctx) error {
ProxyURL: d.Cfg.Fetcher.ProxyURL,
})
if err != nil {
return failed(c, err.Error(), 500)
logrus.WithError(err).Error("Failed to build subscription")
return failed(c, "Failed to build subscription", 500)
}
return d.sendDownloadResponse(c, result, target)
}
+31
View File
@@ -1836,6 +1836,37 @@ func TestHandleDownloadSourceDisabled(t *testing.T) {
assertStatus(t, "DownloadSource disabled", code, 404)
}
func TestHandleDownloadBuildErrorDoesNotLeakUpstreamURL(t *testing.T) {
for _, kind := range []string{"source", "collection"} {
t.Run(kind, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
upstreamURL := server.URL + "/subscription?token=upstream-secret"
server.Close()
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "remote", Name: "Remote", Type: "remote", URL: upstreamURL, Enabled: true})
path := "/sources/remote/dl-tok?target=json"
if kind == "collection" {
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "remote", Name: "Remote", SourceIds: []string{"remote"}, Enabled: true})
path = "/collections/remote/dl-tok?target=json"
}
code, body := doRequest(t, app, "GET", path, "", nil)
assertStatus(t, kind, code, http.StatusInternalServerError)
errorBody, ok := body["error"].(map[string]any)
if !ok || errorBody["message"] != "Failed to build subscription" {
t.Fatalf("response = %v, want fixed generic error", body)
}
response, _ := json.Marshal(body)
if strings.Contains(string(response), "upstream-secret") || strings.Contains(string(response), upstreamURL) {
t.Fatalf("response leaked upstream URL: %s", response)
}
})
}
}
// ---------------------------------------------------------------------------
// Preview handlers
// ---------------------------------------------------------------------------
+17 -18
View File
@@ -2,9 +2,9 @@ package service
import (
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"sort"
@@ -87,7 +87,10 @@ func loadProxyNodes(ctx context.Context, opts BuildOptions) ([]model.ProxyNode,
i := i
sub := sub
tasks[i] = func() ([]model.ProxyNode, error) {
raw, meta := loadSubscriptionRaw(ctx, sub, opts)
raw, meta, err := loadSubscriptionRaw(ctx, sub, opts)
if err != nil {
return nil, err
}
metadataByIndex[i] = meta
nodes := proxy.ParseProxies(raw)
originalCounts[i] = len(nodes)
@@ -193,14 +196,14 @@ func getSources(opts BuildOptions) []model.SourceRecord {
return result
}
func loadSubscriptionRaw(ctx context.Context, sub model.SourceRecord, opts BuildOptions) (string, model.SubscriptionResponseMetadata) {
func loadSubscriptionRaw(ctx context.Context, sub model.SourceRecord, opts BuildOptions) (string, model.SubscriptionResponseMetadata, error) {
if sub.Type == "local" || sub.Content != "" {
return sub.Content + sub.URL, metadataFromSource(sub)
return sub.Content, metadataFromSource(sub), nil
}
urls := splitSourceUrls(sub.URL)
if len(urls) == 0 {
return "", metadataFromSource(sub)
return "", metadataFromSource(sub), nil
}
if len(urls) > util.MaxRemoteSourceUrls {
urls = urls[:util.MaxRemoteSourceUrls]
@@ -215,7 +218,10 @@ func loadSubscriptionRaw(ctx context.Context, sub model.SourceRecord, opts Build
}
}
results, _ := RunWithConcurrencyT(tasks, getConcurrency(opts.Settings), getConcurrencyWait(opts.Settings))
results, err := RunWithConcurrency(tasks, getConcurrency(opts.Settings), getConcurrencyWait(opts.Settings))
if err != nil {
return "", model.SubscriptionResponseMetadata{}, err
}
var contents []string
var metadata model.SubscriptionResponseMetadata
@@ -226,7 +232,7 @@ func loadSubscriptionRaw(ctx context.Context, sub model.SourceRecord, opts Build
}
}
return strings.Join(contents, "\n"), metadata
return strings.Join(contents, "\n"), metadata, nil
}
type fetchResult struct {
@@ -491,6 +497,7 @@ func RunWithConcurrency[T any](tasks []func() (T, error), concurrency int, wait
concurrency = len(tasks)
}
results := make([]T, len(tasks))
errs := make([]error, len(tasks))
cursor := 0
var mu sync.Mutex
var wg sync.WaitGroup
@@ -512,8 +519,8 @@ func RunWithConcurrency[T any](tasks []func() (T, error), concurrency int, wait
}
val, err := tasks[idx]()
if err != nil {
results[idx] = val // zero value
// In non-settled mode, we could cancel, but let's keep it simple
results[idx] = val
errs[idx] = err
logrus.WithError(err).Warn("task failed")
continue
}
@@ -522,12 +529,7 @@ func RunWithConcurrency[T any](tasks []func() (T, error), concurrency int, wait
}()
}
wg.Wait()
return results, nil
}
// RunWithConcurrencyT is a variant for tasks returning a value + metadata.
func RunWithConcurrencyT[T any](tasks []func() (T, error), concurrency int, wait time.Duration) ([]T, error) {
return RunWithConcurrency(tasks, concurrency, wait)
return results, errors.Join(errs...)
}
// RunSettledWithConcurrency runs tasks with allSettled semantics.
@@ -618,9 +620,6 @@ func (aw *AsyncWriter) WaitWithTimeout(timeout time.Duration) {
}
}
// Prevent unused import
var _ = math.MaxInt32
// buildHTTPClient creates an *http.Client with optional proxy support.
// If proxyURL is empty, a standard client is returned.
func buildHTTPClient(timeout time.Duration, proxyURL string) *http.Client {
+66 -23
View File
@@ -2,7 +2,11 @@ package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -38,6 +42,50 @@ func TestBuildSubscriptionResult_LocalSource(t *testing.T) {
}
}
func TestBuildSubscriptionResult_RealityVlessSubscription(t *testing.T) {
content := strings.Join([]string{
"vless://00000000-0000-0000-0000-000000000001@8.220.220.41:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=dl.google.com&fp=chrome&pbk=public-key-1&sid=short-id-1&spx=%2F&type=tcp&headerType=none#ali-seoul",
"vless://00000000-0000-0000-0000-000000000002@8.216.16.28:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.yahoo.co.jp&fp=chrome&pbk=public-key-2&sid=short-id-2&spx=%2F&type=tcp&headerType=none#Ali-Tokyo",
"vless://00000000-0000-0000-0000-000000000003@67.215.229.50:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.cloudflare.com&fp=chrome&pbk=public-key-3&sid=short-id-3&spx=%2F&type=tcp&headerType=none#racknerd-la",
}, "\n")
source := &model.SourceRecord{ID: "reality", Type: "local", Content: content, Enabled: true}
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{Source: source, Target: "json"})
if err != nil {
t.Fatalf("build failed: %v", err)
}
if result.OriginalNodes != 3 || result.Nodes != 3 {
t.Fatalf("nodes = %d/%d, want 3/3", result.OriginalNodes, result.Nodes)
}
var output struct {
Proxies []model.ProxyNode `json:"proxies"`
}
if err := json.Unmarshal([]byte(result.Body), &output); err != nil {
t.Fatalf("decode output: %v", err)
}
for _, node := range output.Proxies {
if node["type"] != "vless" || node["flow"] != "xtls-rprx-vision" || node["network"] != "tcp" {
t.Fatalf("VLESS fields lost: %#v", node)
}
if reality, ok := node["reality-opts"].(map[string]any); !ok || reality["public-key"] == "" || reality["short-id"] == "" {
t.Fatalf("Reality fields lost: %#v", node["reality-opts"])
}
}
}
func TestBuildSubscriptionResult_RemoteFetchError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "blocked", http.StatusForbidden)
}))
defer server.Close()
source := &model.SourceRecord{ID: "blocked", Name: "blocked", Type: "remote", URL: server.URL, Enabled: true}
_, err := BuildSubscriptionResult(context.Background(), BuildOptions{Source: source, Target: "json"})
if err == nil || !strings.Contains(err.Error(), "Remote source blocked failed: 403") {
t.Fatalf("error = %v, want upstream 403", err)
}
}
func TestBuildSubscriptionResult_NoEnabledSources(t *testing.T) {
source := &model.SourceRecord{
ID: "test-src",
@@ -520,18 +568,19 @@ func TestRunWithConcurrency(t *testing.T) {
}
func TestRunWithConcurrency_WithErrors(t *testing.T) {
taskErr := errors.New("task error")
tasks := make([]func() (int, error), 5)
for i := range tasks {
i := i
if i == 2 {
tasks[i] = func() (int, error) { return 0, errors.New("task error") }
tasks[i] = func() (int, error) { return 0, taskErr }
} else {
tasks[i] = func() (int, error) { return i, nil }
}
}
results, err := RunWithConcurrency(tasks, 2, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
if !errors.Is(err, taskErr) {
t.Fatalf("error = %v, want task error", err)
}
if len(results) != 5 {
t.Fatalf("expected 5 results, got %d", len(results))
@@ -567,21 +616,6 @@ func TestRunWithConcurrency_ConcurrencyOne(t *testing.T) {
}
}
func TestRunWithConcurrencyT(t *testing.T) {
tasks := make([]func() (string, error), 3)
for i := range tasks {
i := i
tasks[i] = func() (string, error) { return string(rune('a' + i)), nil }
}
results, err := RunWithConcurrencyT(tasks, 2, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 3 {
t.Errorf("expected 3 results, got %d", len(results))
}
}
func TestRunSettledWithConcurrency(t *testing.T) {
tasks := make([]func() (int, error), 5)
for i := range tasks {
@@ -668,7 +702,10 @@ func TestLoadSubscriptionRaw_LocalSource(t *testing.T) {
Type: "local",
Content: "ss://pass@host:80#Node1",
}
raw, meta := loadSubscriptionRaw(context.Background(), sub, BuildOptions{})
raw, meta, err := loadSubscriptionRaw(context.Background(), sub, BuildOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if raw != "ss://pass@host:80#Node1" {
t.Errorf("expected content, got %s", raw)
}
@@ -682,7 +719,10 @@ func TestLoadSubscriptionRaw_RemoteNoUrls(t *testing.T) {
Type: "remote",
URL: "",
}
raw, _ := loadSubscriptionRaw(context.Background(), sub, BuildOptions{})
raw, _, err := loadSubscriptionRaw(context.Background(), sub, BuildOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if raw != "" {
t.Errorf("expected empty, got %s", raw)
}
@@ -695,9 +735,12 @@ func TestLoadSubscriptionRaw_ContentPresent(t *testing.T) {
Content: "ss://pass@host:80#Node1",
URL: "https://example.com/sub",
}
raw, _ := loadSubscriptionRaw(context.Background(), sub, BuildOptions{})
if raw != "ss://pass@host:80#Node1https://example.com/sub" {
t.Errorf("expected content+url, got %s", raw)
raw, _, err := loadSubscriptionRaw(context.Background(), sub, BuildOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if raw != "ss://pass@host:80#Node1" {
t.Errorf("expected content only, got %s", raw)
}
}