/
githubmirror
/
photoprism
Обзор
Документация
Войти
/
githubmirror
/
photoprism
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
internal/ai/vision/api_client.go
167 строк
4 KB
Michael Mayer
Vision: Retry HTTP 429 via new pkg/http/client backoff wrapper #5729
18 июл 2026, 14:27
18 июл 2026, 14:27
77ead7f
Код
Авторство
О чём код?
package vision import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "github.com/sirupsen/logrus" "github.com/photoprism/photoprism/internal/ai/vision/ollama" "github.com/photoprism/photoprism/pkg/clean" httpclient "github.com/photoprism/photoprism/pkg/http/client" "github.com/photoprism/photoprism/pkg/http/header" "github.com/photoprism/photoprism/pkg/http/safe" ) // PerformApiRequest performs a Vision API request and returns the result. func PerformApiRequest(apiRequest *ApiRequest, uri, method, key string) (apiResponse *ApiResponse, err error) { if apiRequest == nil { return apiResponse, errors.New("api request is nil") } else if err = validateApiRequestURL(uri); err != nil { return apiResponse, err } data, jsonErr := apiRequest.JSON() if jsonErr != nil { return apiResponse, jsonErr } // Bound the total request time, including any 429 retries, to ServiceTimeout. ctx, cancel := context.WithTimeout(context.Background(), ServiceTimeout) defer cancel() // Create HTTP client and a factory that builds a fresh authenticated request // per attempt, so a buffered payload is replayed safely when retrying a 429. client := http.Client{Timeout: ServiceTimeout} newReq := func() (*http.Request, error) { req, reqErr := http.NewRequestWithContext(ctx, method, uri, bytes.NewReader(data)) if reqErr != nil { return nil, reqErr } // Add "application/json" content type header. header.SetContentType(req, header.ContentTypeJson) // Add an authentication header if an access token is provided. if key != "" { header.SetAuthorization(req, key) } // Add custom OpenAI organization and project headers. if apiRequest.GetResponseFormat() == ApiFormatOpenAI { header.SetOpenAIOrg(req, apiRequest.Org) header.SetOpenAIProject(req, apiRequest.Project) } return req, nil } // Perform API request, retrying transient HTTP 429 responses with bounded // exponential backoff while other statuses stay terminal. // #nosec G704 URI is validated by validateApiRequestURL before issuing the request. clientResp, clientErr := httpclient.Do(ctx, &client, newReq, httpclient.RetryPolicy{ MaxRetries: ServiceMaxRetries, BaseDelay: ServiceRetryDelay, MaxDelay: ServiceRetryMaxDelay, RetryStatuses: []int{http.StatusTooManyRequests}, HonorRetryAfter: true, }) if clientErr != nil { return apiResponse, clientErr } defer func() { _ = clientResp.Body.Close() }() body, apiErr := io.ReadAll(io.LimitReader(clientResp.Body, MaxResponseBytes+1)) if apiErr != nil { return nil, apiErr } else if int64(len(body)) > MaxResponseBytes { return nil, fmt.Errorf("vision: response exceeds the maximum size of %d bytes", MaxResponseBytes) } format := apiRequest.GetResponseFormat() if engine, ok := EngineFor(format); ok && engine.Parser != nil { if clientResp.StatusCode >= 300 { log.Debugf("vision: %s (status code %d)", body, clientResp.StatusCode) } parsed, parseErr := engine.Parser.Parse(context.Background(), apiRequest, body, clientResp.StatusCode) if parseErr != nil { return nil, parseErr } if log.IsLevelEnabled(logrus.TraceLevel) { log.Tracef("vision: response %s", string(body)) } return parsed, nil } apiResponse = &ApiResponse{} // Parse and return response, or an error if the request failed. switch format { case ApiFormatVision: if apiErr = json.Unmarshal(body, apiResponse); apiErr != nil { return apiResponse, apiErr } else if clientResp.StatusCode >= 300 { log.Debugf("vision: %s (status code %d)", body, clientResp.StatusCode) } default: return apiResponse, fmt.Errorf("unsupported response format %s", clean.Log(apiRequest.ResponseFormat)) } return apiResponse, nil } // validateApiRequestURL checks that outbound API requests only use HTTP(S) URLs with a host. func validateApiRequestURL(rawURL string) error { _, err := safe.URL(rawURL) return err } func decodeOllamaResponse(data []byte) (*ollama.Response, error) { resp := &ollama.Response{} dec := json.NewDecoder(bytes.NewReader(data)) for { var chunk ollama.Response if err := dec.Decode(&chunk); err != nil { if errors.Is(err, io.EOF) { break } return nil, err } *resp = chunk } return resp, nil } func parseOllamaLabels(raw string) ([]LabelResult, error) { cleaned := clean.JSON(raw) if cleaned == "" { return nil, nil } var payload struct { Labels []LabelResult `json:"labels"` } if err := json.Unmarshal([]byte(cleaned), &payload); err != nil { return nil, err } return payload.Labels, nil }