Skip to content

Commit ec05fe8

Browse files
Merge pull request #162 from Vittuu/codex/source-diagnostics-health
feat: add source diagnostics health checks
2 parents bbc4636 + d333714 commit ec05fe8

14 files changed

Lines changed: 957 additions & 41 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ jobs:
8484
shell: bash
8585
run: go test -race -v -coverprofile=coverage.out ./...
8686

87+
- name: Run source health diagnostics
88+
if: runner.os == 'Linux'
89+
shell: bash
90+
run: go test -tags sourcehealth -run TestSourceHealthLive -count=1 -v ./internal/scraper
91+
8792
build-binaries:
8893
needs: test
8994
runs-on: ${{ matrix.os }}
@@ -385,4 +390,4 @@ jobs:
385390
path: |
386391
dist/GoAnime-Installer-*.exe
387392
dist/GoAnime-Installer-*.sha256
388-
retention-days: 30
393+
retention-days: 30
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Source diagnostics test plan
2+
3+
Este roteiro ajuda a separar indisponibilidade da source de bug no GoAnime.
4+
5+
## Objetivo
6+
7+
- `SourceUnavailable`: 521, 522, 523, 524, 530, DNS error, timeout de conexao ou origem fora devem virar skip no health check.
8+
- `BlockedOrChallenge`: 403, 429, 1020, captcha ou challenge devem virar skip no health check.
9+
- `ParserBroken`: resposta 200 OK sem seletores, JSON ou resultados esperados deve falhar no health check.
10+
- `DecryptBroken`: decrypt/API retornou formato invalido deve falhar no health check.
11+
- `DownloadExpired`: link CDN extraido retornou 403/404 deve ser diagnosticado como link expirado.
12+
- `InternalBug`: panic, nil pointer, loop infinito ou erro local deve falhar.
13+
14+
## Comandos locais
15+
16+
Rode estes comandos antes de abrir ou atualizar a PR:
17+
18+
```powershell
19+
go test ./internal/scraper -count=1 -v
20+
go test ./internal/player -count=1 -v
21+
go test -tags sourcehealth -run TestSourceHealthLive -count=1 -v ./internal/scraper
22+
$env:CI='true'; go test ./... -count=1
23+
go vet ./...
24+
golangci-lint run --timeout=15m
25+
gosec ./...
26+
govulncheck ./...
27+
git diff --check
28+
```
29+
30+
## Health check live
31+
32+
O teste `TestSourceHealthLive` faz uma busca conhecida por provider:
33+
34+
- Anime/geral: `naruto`
35+
- Filmes/series: `dexter`
36+
37+
Resultado esperado:
38+
39+
- Source offline, Cloudflare 521/522/523/524/530, DNS ou timeout: `t.Skip`.
40+
- Captcha, challenge, 403/429/1020: `t.Skip`.
41+
- 200 OK com parser quebrado ou zero resultados para query conhecida: `t.Fatal`.
42+
- Decrypt quebrado ou erro interno: `t.Fatal`.
43+
44+
## App e logs
45+
46+
Mensagens esperadas:
47+
48+
- `FlixHQ temporariamente indisponivel: Cloudflare 521/origem fora`
49+
- `SFlix bloqueou a requisicao: captcha/challenge`
50+
- `Goyabu respondeu, mas o parser nao encontrou os dados esperados`
51+
- `Download link de download expirou ou foi negado: HTTP 404`
52+
53+
Depois de 3 falhas consecutivas de origem/bloqueio, o circuit breaker pula a source por 10 minutos para evitar martelar servidor fora.
54+
55+
## Discord
56+
57+
O projeto ja possui Discord Rich Presence local, mas isso nao e a mesma coisa que alertas de saude do projeto. Para publicar diagnosticos em um canal do Discord com seguranca, use uma PR separada com:
58+
59+
- `DISCORD_WEBHOOK_URL` configurado como GitHub secret.
60+
- Um job agendado ou manual que rode `go test -tags sourcehealth -run TestSourceHealthLive -count=1 -v ./internal/scraper`.
61+
- Um passo que envie apenas o resumo de sources `healthy`, `skipped` e `failed`, sem expor tokens, cookies ou URLs privadas.
62+
63+
Sem esse secret configurado, a opcao segura e manter as informacoes no log do CI e no output local.

internal/api/flixhq_flow_test.go

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
package api
44

55
import (
6-
"strings"
76
"testing"
87

98
"github.com/alvarorichard/Goanime/internal/models"
@@ -17,26 +16,7 @@ func isTransientError(err error) bool {
1716
if err == nil {
1817
return false
1918
}
20-
msg := err.Error()
21-
transient := []string{
22-
"context deadline exceeded",
23-
"connection refused",
24-
"no such host",
25-
"timeout",
26-
"500", "502", "503", "530", "405",
27-
"Bad Gateway",
28-
"Method Not Allowed",
29-
"both APIs failed",
30-
"i/o timeout",
31-
"TLS handshake timeout",
32-
"no server found",
33-
}
34-
for _, s := range transient {
35-
if strings.Contains(msg, s) {
36-
return true
37-
}
38-
}
39-
return false
19+
return scraper.DiagnoseError("FlixHQ", "integration", err).ShouldSkipHealthCheck()
4020
}
4121

4222
func TestFlixHQFullFlow(t *testing.T) {

internal/player/download.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/alvarorichard/Goanime/internal/api"
2525
"github.com/alvarorichard/Goanime/internal/downloader/hls"
2626
"github.com/alvarorichard/Goanime/internal/models"
27+
"github.com/alvarorichard/Goanime/internal/scraper"
2728
"github.com/alvarorichard/Goanime/internal/tui"
2829
"github.com/alvarorichard/Goanime/internal/util"
2930
"github.com/ktr0731/go-fuzzyfinder"
@@ -87,10 +88,15 @@ func downloadPart(url string, from, to int64, part int, client *http.Client, des
8788
}
8889

8990
if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK {
91+
statusCode := resp.StatusCode
92+
status := resp.Status
9093
if cErr := resp.Body.Close(); cErr != nil {
9194
util.Logger.Warn("Error closing response body", "error", cErr)
9295
}
93-
util.Debugf("Download part %d: unexpected status %d", part, resp.StatusCode)
96+
if statusCode == http.StatusForbidden || statusCode == http.StatusNotFound {
97+
return scraper.NewDownloadExpiredError("Download", "http-range", statusCode, fmt.Errorf("HTTP %d: %s", statusCode, status))
98+
}
99+
util.Debugf("Download part %d: unexpected status %d", part, statusCode)
94100
staleRetries++
95101
continue
96102
}
@@ -1018,6 +1024,9 @@ func downloadDirectHTTPWithClient(videoURL, path string, m *model, client *http.
10181024
}()
10191025

10201026
if resp.StatusCode != http.StatusOK {
1027+
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound {
1028+
return scraper.NewDownloadExpiredError("Download", "http", resp.StatusCode, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status))
1029+
}
10211030
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
10221031
}
10231032

internal/player/download_regression_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"charm.land/log/v2"
1818
"github.com/alvarorichard/Goanime/internal/models"
19+
"github.com/alvarorichard/Goanime/internal/scraper"
1920
"github.com/alvarorichard/Goanime/internal/util"
2021
"github.com/stretchr/testify/assert"
2122
"github.com/stretchr/testify/require"
@@ -296,6 +297,9 @@ func TestDownloadDirectHTTPWithClientReturnsHTTPStatusErrorFromMockCDN(t *testin
296297
err := downloadDirectHTTPWithClient(server.URL+"/missing.mp4", outPath, &model{}, server.Client())
297298
require.Error(t, err)
298299
assert.True(t, isHTTPStatusError(err, http.StatusNotFound), "error should be recognized as HTTP 404: %v", err)
300+
diagnostic := scraper.DiagnoseError("Download", "http", err)
301+
require.NotNil(t, diagnostic)
302+
assert.Equal(t, scraper.DiagnosticDownloadExpired, diagnostic.Kind)
299303

300304
_, statErr := os.Stat(outPath)
301305
assert.True(t, os.IsNotExist(statErr), "404 response must not create a completed file")

internal/player/scraper.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ func getContentLength(url string, client *http.Client) (int64, error) {
125125

126126
// Checks if the server responded with a 200 OK or 206 Partial Content status.
127127
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
128+
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound {
129+
return 0, scraper.NewDownloadExpiredError("Download", "content-length", resp.StatusCode, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status))
130+
}
128131
// Returns an error if the server does not support partial content (required for ranged requests).
129132
return 0, fmt.Errorf("server does not support partial content: status code %d", resp.StatusCode)
130133
}

internal/scraper/errors.go

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,8 @@ var ErrInvalidStreamURL = errors.New("invalid stream url")
2323
// checkHTTPStatus wraps blocking upstream statuses with ErrSourceUnavailable so
2424
// callers can differentiate provider-side issues from local parsing failures.
2525
func checkHTTPStatus(resp *http.Response, source string) error {
26-
switch resp.StatusCode {
27-
case http.StatusForbidden, http.StatusTooManyRequests, http.StatusServiceUnavailable:
28-
return fmt.Errorf("%s returned status %d (source blocked?): %w", source, resp.StatusCode, ErrSourceUnavailable)
29-
}
3026
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
31-
return fmt.Errorf("%s returned status %d", source, resp.StatusCode)
27+
return NewHTTPStatusError(source, "http", resp.StatusCode)
3228
}
3329
return nil
3430
}
@@ -37,12 +33,12 @@ func checkHTTPStatus(resp *http.Response, source string) error {
3733
// are expected.
3834
func checkHTMLResponse(resp *http.Response, body []byte, source string) error {
3935
if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") {
40-
return fmt.Errorf("%s returned HTML instead of JSON (source blocked?): %w", source, ErrSourceUnavailable)
36+
return NewBlockedChallengeError(source, "http", "returned HTML instead of JSON", nil)
4137
}
4238

4339
trimmed := bytes.TrimLeft(body, " \t\r\n")
4440
if len(trimmed) > 0 && trimmed[0] == '<' {
45-
return fmt.Errorf("%s returned HTML instead of JSON (source blocked?): %w", source, ErrSourceUnavailable)
41+
return NewBlockedChallengeError(source, "http", "returned HTML instead of JSON", nil)
4642
}
4743

4844
return nil
@@ -53,16 +49,16 @@ func checkHTMLResponse(resp *http.Response, body []byte, source string) error {
5349
func checkChallengeDocument(doc *goquery.Document, source string) error {
5450
title := strings.ToLower(strings.TrimSpace(doc.Find("title").First().Text()))
5551
if strings.Contains(title, "just a moment") {
56-
return fmt.Errorf("%s returned a challenge page: %w", source, ErrSourceUnavailable)
52+
return NewBlockedChallengeError(source, "http", "returned a challenge page", nil)
5753
}
5854

5955
if doc.Find("#cf-wrapper").Length() > 0 || doc.Find("#challenge-form").Length() > 0 {
60-
return fmt.Errorf("%s returned a challenge page: %w", source, ErrSourceUnavailable)
56+
return NewBlockedChallengeError(source, "http", "returned a challenge page", nil)
6157
}
6258

6359
body := strings.ToLower(doc.Text())
6460
if strings.Contains(body, "cf-error") || strings.Contains(body, "cloudflare") {
65-
return fmt.Errorf("%s returned a challenge page: %w", source, ErrSourceUnavailable)
61+
return NewBlockedChallengeError(source, "http", "returned a challenge page", nil)
6662
}
6763

6864
return nil

internal/scraper/source_circuit.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Package scraper implements provider search, stream extraction, and source diagnostics.
2+
package scraper
3+
4+
import (
5+
"fmt"
6+
"sync"
7+
"time"
8+
)
9+
10+
const (
11+
defaultSourceFailureThreshold = 3
12+
defaultSourceCooldown = 10 * time.Minute
13+
)
14+
15+
type sourceCircuitState struct {
16+
failures int
17+
openUntil time.Time
18+
lastDiagnostic *SourceDiagnostic
19+
}
20+
21+
type sourceCircuitBreaker struct {
22+
mu sync.Mutex
23+
threshold int
24+
cooldown time.Duration
25+
now func() time.Time
26+
states map[ScraperType]*sourceCircuitState
27+
}
28+
29+
func newSourceCircuitBreaker() *sourceCircuitBreaker {
30+
return &sourceCircuitBreaker{
31+
threshold: defaultSourceFailureThreshold,
32+
cooldown: defaultSourceCooldown,
33+
now: time.Now,
34+
states: make(map[ScraperType]*sourceCircuitState),
35+
}
36+
}
37+
38+
func (cb *sourceCircuitBreaker) isOpen(source ScraperType) (time.Time, *SourceDiagnostic, bool) {
39+
if cb == nil {
40+
return time.Time{}, nil, false
41+
}
42+
43+
cb.mu.Lock()
44+
defer cb.mu.Unlock()
45+
46+
state := cb.states[source]
47+
if state == nil || state.openUntil.IsZero() {
48+
return time.Time{}, nil, false
49+
}
50+
51+
now := cb.now()
52+
if !now.Before(state.openUntil) {
53+
state.openUntil = time.Time{}
54+
state.failures = 0
55+
state.lastDiagnostic = nil
56+
return time.Time{}, nil, false
57+
}
58+
59+
return state.openUntil, state.lastDiagnostic, true
60+
}
61+
62+
func (cb *sourceCircuitBreaker) recordSuccess(source ScraperType) {
63+
if cb == nil {
64+
return
65+
}
66+
67+
cb.mu.Lock()
68+
defer cb.mu.Unlock()
69+
delete(cb.states, source)
70+
}
71+
72+
func (cb *sourceCircuitBreaker) recordFailure(source ScraperType, diagnostic *SourceDiagnostic) bool {
73+
if cb == nil || diagnostic == nil || !diagnostic.ShouldOpenCircuit() {
74+
return false
75+
}
76+
77+
cb.mu.Lock()
78+
defer cb.mu.Unlock()
79+
80+
state := cb.states[source]
81+
if state == nil {
82+
state = &sourceCircuitState{}
83+
cb.states[source] = state
84+
}
85+
86+
state.failures++
87+
state.lastDiagnostic = diagnostic
88+
if state.failures < cb.threshold {
89+
return false
90+
}
91+
92+
state.openUntil = cb.now().Add(cb.cooldown)
93+
return true
94+
}
95+
96+
func (sm *ScraperManager) ensureCircuitBreaker() *sourceCircuitBreaker {
97+
sm.breakerMu.Lock()
98+
defer sm.breakerMu.Unlock()
99+
100+
if sm.breaker == nil {
101+
sm.breaker = newSourceCircuitBreaker()
102+
}
103+
return sm.breaker
104+
}
105+
106+
func (sm *ScraperManager) circuitOpenDiagnostic(source ScraperType) (*SourceDiagnostic, time.Duration, bool) {
107+
breaker := sm.ensureCircuitBreaker()
108+
openUntil, lastDiagnostic, ok := breaker.isOpen(source)
109+
if !ok {
110+
return nil, 0, false
111+
}
112+
113+
sourceName := sm.getScraperDisplayName(source)
114+
message := fmt.Sprintf("circuit breaker open until %s", openUntil.Format(time.RFC3339))
115+
if lastDiagnostic != nil {
116+
message = fmt.Sprintf("%s; last failure: %s", message, lastDiagnostic.UserMessage())
117+
}
118+
119+
diagnostic := &SourceDiagnostic{
120+
Source: sourceName,
121+
Layer: "circuit-breaker",
122+
Kind: DiagnosticSourceUnavailable,
123+
Message: message,
124+
Err: ErrSourceUnavailable,
125+
}
126+
127+
return diagnostic, time.Until(openUntil), true
128+
}
129+
130+
func (sm *ScraperManager) recordSourceSuccess(source ScraperType) {
131+
sm.ensureCircuitBreaker().recordSuccess(source)
132+
}
133+
134+
func (sm *ScraperManager) recordSourceFailure(source ScraperType, diagnostic *SourceDiagnostic) bool {
135+
return sm.ensureCircuitBreaker().recordFailure(source, diagnostic)
136+
}

0 commit comments

Comments
 (0)