Files
godoxy-yusing/internal/homepage/icons/fetch/fetch.go
yusing 373372ac59 refactor(homepage/icon): check service health before fetching icons and add retry logic
The icon fetching logic now checks if the target service is healthy before
attempting to fetch icons. If the health monitor reports an unhealthy status,
the function returns HTTP 503 Service Unavailable instead of proceeding.

Additionally, the icon cache lookup now includes infinite retry logic with a
15-second backoff interval, improving resilience during transient service
outages. Previously, failed lookups would not be retried.

The `route` interface was extended with a `HealthMonitor()` method to support
the health check functionality.
2026-01-09 22:38:31 +08:00

261 lines
8.0 KiB
Go

package iconfetch
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
"github.com/vincent-petithory/dataurl"
"github.com/yusing/godoxy/internal/homepage/icons"
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/cache"
httputils "github.com/yusing/goutils/http"
strutils "github.com/yusing/goutils/strings"
)
type Result struct {
Icon []byte
StatusCode int
contentType string
}
func FetchResultWithErrorf(statusCode int, msgFmt string, args ...any) (Result, error) {
return Result{StatusCode: statusCode}, fmt.Errorf(msgFmt, args...)
}
func FetchResultOK(icon []byte, contentType string) (Result, error) {
return Result{Icon: icon, contentType: contentType}, nil
}
func GinError(c *gin.Context, statusCode int, err error) {
if statusCode == 0 {
statusCode = http.StatusInternalServerError
}
if statusCode == http.StatusInternalServerError {
c.Error(apitypes.InternalServerError(err, "unexpected error"))
} else {
c.JSON(statusCode, apitypes.Error(err.Error()))
}
}
const faviconFetchTimeout = 3 * time.Second
func (res *Result) ContentType() string {
if res.contentType == "" {
if bytes.HasPrefix(res.Icon, []byte("<svg")) || bytes.HasPrefix(res.Icon, []byte("<?xml")) {
return "image/svg+xml"
}
return "image/x-icon"
}
return res.contentType
}
const maxRedirectDepth = 5
func FetchFavIconFromURL(ctx context.Context, iconURL *icons.URL) (Result, error) {
switch iconURL.Source {
case icons.SourceAbsolute:
return FetchIconAbsolute(ctx, iconURL.URL())
case icons.SourceRelative:
return FetchResultWithErrorf(http.StatusBadRequest, "unexpected relative icon")
case icons.SourceWalkXCode, icons.SourceSelfhSt:
return fetchKnownIcon(ctx, iconURL)
}
return FetchResultWithErrorf(http.StatusBadRequest, "invalid icon source")
}
var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (Result, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err)
}
resp, err := gphttp.Do(req)
if err == nil {
defer resp.Body.Close()
} else {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return FetchResultWithErrorf(http.StatusBadGateway, "request timeout")
}
return FetchResultWithErrorf(http.StatusBadGateway, "connection error: %w", err)
}
if resp.StatusCode != http.StatusOK {
return FetchResultWithErrorf(resp.StatusCode, "upstream error: http %d", resp.StatusCode)
}
icon, err := io.ReadAll(resp.Body)
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to read response body: %w", err)
}
if len(icon) == 0 {
return FetchResultWithErrorf(http.StatusNotFound, "empty icon")
}
res := Result{Icon: icon}
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
res.contentType = contentType
}
// else leave it empty
return res, nil
}).WithMaxEntries(200).WithRetriesExponentialBackoff(3).WithTTL(4 * time.Hour).Build()
var nameSanitizer = strings.NewReplacer(
"_", "-",
" ", "-",
"(", "",
")", "",
)
func sanitizeName(name string) string {
return strings.ToLower(nameSanitizer.Replace(name))
}
func fetchKnownIcon(ctx context.Context, url *icons.URL) (Result, error) {
// if icon isn't in the list, no need to fetch
if !url.HasIcon() {
return Result{StatusCode: http.StatusNotFound}, errors.New("no such icon")
}
return FetchIconAbsolute(ctx, url.URL())
}
func fetchIcon(ctx context.Context, filename string) (Result, error) {
for _, fileType := range []string{"svg", "webp", "png"} {
result, err := fetchKnownIcon(ctx, icons.NewURL(icons.SourceSelfhSt, filename, fileType))
if err == nil {
return result, err
}
result, err = fetchKnownIcon(ctx, icons.NewURL(icons.SourceWalkXCode, filename, fileType))
if err == nil {
return result, err
}
}
return FetchResultWithErrorf(http.StatusNotFound, "no icon found")
}
type contextValue struct {
r httpRoute
uri string
}
func FindIcon(ctx context.Context, r route, uri string, variant icons.Variant) (Result, error) {
for _, ref := range r.References() {
ref = sanitizeName(ref)
if variant != icons.VariantNone {
ref += "-" + string(variant)
}
result, err := fetchIcon(ctx, ref)
if err == nil {
return result, err
}
}
if r, ok := r.(httpRoute); ok {
if mon := r.HealthMonitor(); mon != nil && !mon.Status().Good() {
return FetchResultWithErrorf(http.StatusServiceUnavailable, "service unavailable")
}
// fallback to parse html
return findIconSlowCached(context.WithValue(ctx, "route", contextValue{r: r, uri: uri}), r.Key())
}
return FetchResultWithErrorf(http.StatusNotFound, "no icon found")
}
var findIconSlowCached = cache.NewKeyFunc(func(ctx context.Context, key string) (Result, error) {
v := ctx.Value("route").(contextValue)
return findIconSlow(ctx, v.r, v.uri, nil)
}).WithMaxEntries(200).WithRetriesConstantBackoff(math.MaxInt, 15*time.Second).Build() // infinite retries, 15 seconds interval
func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string) (Result, error) {
select {
case <-ctx.Done():
return FetchResultWithErrorf(http.StatusBadGateway, "request timeout")
default:
}
if len(stack) > maxRedirectDepth {
return FetchResultWithErrorf(http.StatusBadGateway, "too many redirects")
}
ctx, cancel := context.WithTimeoutCause(ctx, faviconFetchTimeout, errors.New("favicon request timeout"))
defer cancel()
newReq, err := http.NewRequestWithContext(ctx, http.MethodGet, r.TargetURL().String(), nil)
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err)
}
newReq.Header.Set("Accept-Encoding", "identity") // disable compression
u, err := url.ParseRequestURI(strutils.SanitizeURI(uri))
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot parse uri: %w", err)
}
newReq.URL.Path = u.Path
newReq.URL.RawPath = u.RawPath
newReq.URL.RawQuery = u.RawQuery
newReq.RequestURI = u.String()
c := newContent()
r.ServeHTTP(c, newReq)
if c.status != http.StatusOK {
switch c.status {
case 0:
return FetchResultWithErrorf(http.StatusBadGateway, "connection error")
default:
if loc := c.Header().Get("Location"); loc != "" {
loc = strutils.SanitizeURI(loc)
if loc == "/" || loc == newReq.URL.Path || slices.Contains(stack, loc) {
return FetchResultWithErrorf(http.StatusBadGateway, "circular redirect")
}
// append current path to stack
// handles redirect to the same path with different query
return findIconSlow(ctx, r, loc, append(stack, newReq.URL.Path))
}
}
return FetchResultWithErrorf(c.status, "upstream error: status %d, %s", c.status, c.data)
}
// return icon data
if !httputils.GetContentType(c.header).IsHTML() {
return FetchResultOK(c.data, c.header.Get("Content-Type"))
}
// try extract from "link[rel=icon]" from path "/"
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to parse html: %w", err)
}
ele := doc.Find("head > link[rel=icon]").First()
if ele.Length() == 0 {
return FetchResultWithErrorf(http.StatusNotFound, "icon element not found")
}
href := ele.AttrOr("href", "")
if href == "" {
return FetchResultWithErrorf(http.StatusNotFound, "icon href not found")
}
// https://en.wikipedia.org/wiki/Data_URI_scheme
if strings.HasPrefix(href, "data:image/") {
dataURI, err := dataurl.DecodeString(href)
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to decode favicon: %w", err)
}
return FetchResultOK(dataURI.Data, dataURI.ContentType())
}
switch {
case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"):
return FetchIconAbsolute(ctx, href)
default:
return findIconSlow(ctx, r, href, append(stack, newReq.URL.Path))
}
}