ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'master' into gpt55switch

authored by

Paul Miller and committed by
GitHub
dc2a47e0 92014536

+234 -131
+4
cmd/albertsonsquery/main.go
··· 10 10 11 11 "careme/internal/albertsons/query" 12 12 "careme/internal/brightdata" 13 + 14 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 13 15 ) 14 16 15 17 func main() { ··· 57 59 ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) 58 60 defer cancel() 59 61 } 62 + 60 63 httpClient, err := brightdata.NewProxyAwareHTTPClient(brightdata.LoadConfig()) 61 64 if err != nil { 62 65 return fmt.Errorf("create HTTP client: %w", err) 63 66 } 67 + httpClient.Transport = otelhttp.NewTransport(httpClient.Transport) 64 68 client, err := query.NewSearchClient(query.SearchClientConfig{ 65 69 BaseURL: baseURL, 66 70 SubscriptionKey: subscriptionKey,
+5 -3
cmd/careme/web.go
··· 29 29 "careme/internal/watchdog" 30 30 31 31 cachepkg "careme/internal/cache" 32 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 32 33 ) 33 34 34 35 func runServer(cfg *config.Config, addr string) error { ··· 58 59 userStorage := users.NewStorage(cache) 59 60 ro := &readyOnce{} 60 61 watchdogServer := watchdog.Server{} 62 + aiHTTPClient := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} 61 63 // TODO make the mock more transparent? 62 - grader := ingredientgrading.NewManager(cfg, cache) 64 + grader := ingredientgrading.NewManager(cfg, cache, aiHTTPClient) 63 65 64 66 var generator recipes.ExtGenerator 65 67 var waitFns []func() 66 68 if cfg.Mocks.Enable { 67 69 generator = recipes.NewMockGenerator() 68 70 } else { 69 - mc := critique.NewManager(cfg, cache) 71 + mc := critique.NewManager(cfg, cache, aiHTTPClient) 70 72 ro.add(mc) 71 73 72 - aiclient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL") 74 + aiclient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL", aiHTTPClient) 73 75 ro.add(aiclient) 74 76 staples, err := recipes.NewCachedStaplesService(cfg, cache, grader) 75 77 if err != nil {
+2 -1
cmd/ingredients/main.go
··· 5 5 "flag" 6 6 "fmt" 7 7 "log" 8 + "net/http" 8 9 "slices" 9 10 "strings" 10 11 ··· 60 61 if err != nil { 61 62 log.Fatalf("failed to create cache for ingredient grading: %s", err) 62 63 } 63 - grader := ingredientgrading.NewManager(cfg, cacheStore) 64 + grader := ingredientgrading.NewManager(cfg, cacheStore, http.DefaultClient) 64 65 graded, err := grader.GradeIngredients(ctx, ings) 65 66 if err != nil { 66 67 log.Fatalf("failed to grade ingredients: %s", err)
+2
go.mod
··· 20 20 github.com/sendgrid/sendgrid-go v3.16.1+incompatible 21 21 github.com/stretchr/testify v1.11.1 22 22 go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 23 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 23 24 go.opentelemetry.io/otel v1.43.0 24 25 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 25 26 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 ··· 48 49 github.com/cespare/xxhash/v2 v2.3.0 // indirect 49 50 github.com/davecgh/go-spew v1.1.1 // indirect 50 51 github.com/emicklei/go-restful/v3 v3.12.2 // indirect 52 + github.com/felixge/httpsnoop v1.0.4 // indirect 51 53 github.com/fxamacker/cbor/v2 v2.9.0 // indirect 52 54 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 53 55 github.com/go-logr/logr v1.4.3 // indirect
+4
go.sum
··· 61 61 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 62 62 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 63 63 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 64 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 65 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 64 66 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 65 67 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 66 68 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= ··· 280 282 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 281 283 go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM= 282 284 go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg= 285 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 286 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 283 287 go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= 284 288 go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= 285 289 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
+22 -22
internal/ai/client.go
··· 8 8 "hash/fnv" 9 9 "io" 10 10 "log/slog" 11 + "net/http" 11 12 "strings" 12 13 "time" 13 14 ··· 20 21 21 22 "github.com/invopop/jsonschema" 22 23 ) 23 - 24 - type client struct { 25 - apiKey string 26 - schema map[string]any 27 - wineSchema map[string]any 28 - model string 29 - wineModel string 30 - } 31 24 32 25 type GeneratedImage struct { 33 26 Body io.Reader ··· 104 97 Commentary string `json:"commentary"` 105 98 } 106 99 100 + type client struct { 101 + schema map[string]any 102 + wineSchema map[string]any 103 + model string 104 + wineModel string 105 + oai openai.Client 106 + } 107 + 107 108 // ignoring model for now. 108 - func NewClient(apiKey, _ string) *client { 109 + func NewClient(apiKey, _ string, httpClient *http.Client) *client { 109 110 // ignor model for now. 110 111 r := jsonschema.Reflector{ 111 112 DoNotReference: true, // no $defs and no $ref ··· 119 120 _ = json.Unmarshal(recipesSchemaJSON, &m) 120 121 var wine map[string]any 121 122 _ = json.Unmarshal(wineSchemaJSON, &wine) 123 + opts := []option.RequestOption{option.WithAPIKey(apiKey)} 124 + if httpClient != nil { 125 + opts = append(opts, option.WithHTTPClient(httpClient)) 126 + } 127 + aiClient := openai.NewClient(opts...) 122 128 return &client{ 123 - apiKey: apiKey, 129 + oai: aiClient, 124 130 schema: m, 125 131 wineSchema: wine, 126 132 model: defaultRecipeModel, ··· 228 234 if previousResponseID == "" { 229 235 return nil, fmt.Errorf("response ID is required for regeneration") 230 236 } 231 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 232 237 messages := cleanInstuctions(instructions) 233 238 234 239 params := responses.ResponseNewParams{ ··· 241 246 Store: openai.Bool(true), 242 247 Text: scheme(c.schema), 243 248 } 244 - resp, err := client.Responses.New(ctx, params) 249 + resp, err := c.oai.Responses.New(ctx, params) 245 250 if err != nil { 246 251 return nil, fmt.Errorf("failed to regenerate recipes: %w", err) 247 252 } ··· 254 259 if question == "" { 255 260 return nil, fmt.Errorf("question is required") 256 261 } 257 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 258 262 259 263 params := responses.ResponseNewParams{ 260 264 Model: c.model, ··· 267 271 if previousResponseID != "" { 268 272 params.PreviousResponseID = openai.String(previousResponseID) 269 273 } 270 - resp, err := client.Responses.New(ctx, params) 274 + resp, err := c.oai.Responses.New(ctx, params) 271 275 if err != nil { 272 276 return nil, fmt.Errorf("failed to answer question: %w", err) 273 277 } ··· 290 294 return nil, fmt.Errorf("failed to build recipe image prompt: %w", err) 291 295 } 292 296 293 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 294 - resp, err := client.Images.Generate(ctx, openai.ImageGenerateParams{ 297 + resp, err := c.oai.Images.Generate(ctx, openai.ImageGenerateParams{ 295 298 Prompt: prompt, 296 299 Model: recipeImageModel, 297 300 N: openai.Int(1), ··· 352 355 if err != nil { 353 356 return nil, fmt.Errorf("failed to build wine selection prompt: %w", err) 354 357 } 355 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 356 358 params := responses.ResponseNewParams{ 357 359 Model: c.wineModel, 358 360 Instructions: openai.String(winePrompt), ··· 361 363 }, 362 364 Text: scheme(c.wineSchema), 363 365 } 364 - resp, err := client.Responses.New(ctx, params) 366 + resp, err := c.oai.Responses.New(ctx, params) 365 367 if err != nil { 366 368 return nil, fmt.Errorf("failed to pick wine: %w", err) 367 369 } ··· 379 381 return nil, fmt.Errorf("failed to build recipe messages: %w", err) 380 382 } 381 383 382 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 383 384 params := responses.ResponseNewParams{ 384 385 Model: c.model, 385 386 Instructions: openai.String(systemMessage), ··· 392 393 } 393 394 // should we stream. Can we pass past generation. 394 395 395 - resp, err := client.Responses.New(ctx, params) 396 + resp, err := c.oai.Responses.New(ctx, params) 396 397 if err != nil { 397 398 return nil, fmt.Errorf("failed to generate recipes: %w", err) 398 399 } ··· 477 478 // more CORRECT to do a very simple response request with allowed tokens 1 but this seems cheaper 478 479 // https://chatgpt.com/share/6984da16-ff88-8009-8486-4e0479ac6a01 479 480 // could only do it once to ensure startup 480 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 481 - _, err := client.Models.List(ctx) 481 + _, err := c.oai.Models.List(ctx) 482 482 return err 483 483 } 484 484
+22 -21
internal/ai/critique.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "net/http" 8 9 "strings" 9 10 "time" 10 11 ··· 50 51 } 51 52 52 53 type critiquer struct { 53 - apiKey string 54 54 model string 55 55 schema map[string]any 56 + gem *genai.Client 56 57 } 57 58 58 - func NewCritiquer(apiKey, model string) *critiquer { 59 + func NewCritiquer(apiKey, model string, httpClient *http.Client) *critiquer { 59 60 model = strings.TrimSpace(model) 60 61 if model == "" { 61 62 model = defaultGeminiCritiqueModel 62 63 } 64 + 65 + if httpClient == nil { 66 + httpClient = http.DefaultClient 67 + } 68 + 69 + // pass in context and return error? seems like context only used in edge case 70 + client, err := genai.NewClient(context.TODO(), &genai.ClientConfig{ 71 + APIKey: apiKey, 72 + Backend: genai.BackendGeminiAPI, 73 + HTTPClient: httpClient, 74 + }) 75 + if err != nil { 76 + panic(err) 77 + } 78 + 63 79 return &critiquer{ 64 - apiKey: strings.TrimSpace(apiKey), 80 + gem: client, 65 81 model: model, 66 82 schema: recipeCritiqueJSONSchema(), 67 83 } 68 84 } 69 85 70 86 func (c *critiquer) Ready(ctx context.Context) error { 71 - client, err := c.newClient(ctx) 72 - if err != nil { 73 - return err 74 - } 75 - for _, err := range client.Models.All(ctx) { 87 + for _, err := range c.gem.Models.All(ctx) { 76 88 return err 77 89 } 78 90 return fmt.Errorf("model not found: %s", c.model) ··· 95 107 if err != nil { 96 108 return nil, fmt.Errorf("failed to build recipe critique prompt: %w", err) 97 109 } 98 - client, err := c.newClient(ctx) 110 + 99 111 if err != nil { 100 112 return nil, err 101 113 } 102 114 start := time.Now() 103 - resp, err := client.Models.GenerateContent(ctx, c.model, genai.Text(prompt), &genai.GenerateContentConfig{ 115 + resp, err := c.gem.Models.GenerateContent(ctx, c.model, genai.Text(prompt), &genai.GenerateContentConfig{ 104 116 SystemInstruction: genai.NewContentFromText(recipeCritiqueSystemInstruction, genai.RoleUser), 105 117 // Temperature: genai.Ptr[float32](0), 106 118 // MaxOutputTokens: 768, ··· 146 158 } 147 159 148 160 return slog.Group("usage", attrs...) 149 - } 150 - 151 - func (c *critiquer) newClient(ctx context.Context) (*genai.Client, error) { 152 - client, err := genai.NewClient(ctx, &genai.ClientConfig{ 153 - APIKey: c.apiKey, 154 - Backend: genai.BackendGeminiAPI, 155 - }) 156 - if err != nil { 157 - return nil, fmt.Errorf("create Gemini client: %w", err) 158 - } 159 - return client, nil 160 161 } 161 162 162 163 func parseRecipeCritique(body string) (*RecipeCritique, error) {
+11 -5
internal/ai/ingredient_grade.go
··· 8 8 "hash/fnv" 9 9 "io" 10 10 "log/slog" 11 + "net/http" 11 12 "strings" 12 13 13 14 "github.com/invopop/jsonschema" ··· 120 121 } 121 122 122 123 type ingredientGrader struct { 123 - apiKey string 124 124 model string 125 125 cacheVersion string 126 126 schema map[string]any 127 + oai openai.Client 127 128 } 128 129 129 130 func ingredientGradeCacheVersion(model, systemInstruction string) string { ··· 133 134 return base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 134 135 } 135 136 136 - func NewIngredientGrader(apiKey, model string) *ingredientGrader { 137 + func NewIngredientGrader(apiKey, model string, httpClient *http.Client) *ingredientGrader { 137 138 model = strings.TrimSpace(model) 138 139 if model == "" { 139 140 model = defaultIngredientGradeModel 140 141 } 142 + opts := []option.RequestOption{option.WithAPIKey(apiKey)} 143 + if httpClient != nil { 144 + opts = append(opts, option.WithHTTPClient(httpClient)) 145 + } 146 + aiClient := openai.NewClient(opts...) 147 + 141 148 return &ingredientGrader{ 142 - apiKey: strings.TrimSpace(apiKey), 149 + oai: aiClient, 143 150 model: model, 144 151 cacheVersion: ingredientGradeCacheVersion(model, ingredientGradeSystemInstruction), 145 152 schema: ingredientGradeJSONSchema(), ··· 169 176 return nil, fmt.Errorf("failed to build ingredient grading prompt: %w", err) 170 177 } 171 178 172 - client := openai.NewClient(option.WithAPIKey(g.apiKey)) 173 - resp, err := client.Responses.New(ctx, responses.ResponseNewParams{ 179 + resp, err := g.oai.Responses.New(ctx, responses.ResponseNewParams{ 174 180 Model: g.model, 175 181 Instructions: openai.String(ingredientGradeSystemInstruction), 176 182 Input: responses.ResponseNewParamsInputUnion{
+4 -1
internal/albertsons/query/client.go
··· 77 77 78 78 httpClient := cfg.HTTPClient 79 79 if httpClient == nil { 80 - httpClient = &http.Client{Timeout: 20 * time.Second} 80 + httpClient = http.DefaultClient 81 81 } 82 82 83 83 return &SearchClient{ ··· 89 89 } 90 90 91 91 func (c *SearchClient) Search(ctx context.Context, storeID, category string, opts SearchOptions) (*PathwaySearchPayload, error) { 92 + ctx, cancel := context.WithTimeout(ctx, time.Second*20) 93 + defer cancel() 94 + 92 95 storeID = strings.TrimSpace(storeID) 93 96 if storeID == "" { 94 97 return nil, errors.New("store id is required")
+18 -8
internal/brightdata/proxy.go
··· 47 47 } 48 48 49 49 func NewProxyAwareHTTPClient(cfg ProxyConfig) (*http.Client, error) { 50 - client := &http.Client{} 51 - if !cfg.Enabled() { 52 - return withRetries(client), nil 50 + transport := http.DefaultTransport 51 + if cfg.Enabled() { 52 + var err error 53 + transport, err = newProxyTransport(cfg) 54 + if err != nil { 55 + return nil, err 56 + } 53 57 } 54 58 59 + client := &http.Client{Transport: transport} 60 + 61 + return withRetries(client), nil 62 + } 63 + 64 + func newProxyTransport(cfg ProxyConfig) (*http.Transport, error) { 55 65 rootCAs, err := proxyRootCAs() 56 66 if err != nil { 57 67 return nil, err ··· 64 74 "username", cfg.Username, 65 75 ) 66 76 67 - transport := http.DefaultTransport.(*http.Transport).Clone() 68 - transport.Proxy = http.ProxyURL(cfg.proxyURL()) 69 - transport.TLSClientConfig = &tls.Config{RootCAs: rootCAs} 70 - client.Transport = transport 71 - return withRetries(client), nil 77 + // this feels funny 78 + proxyTransport := http.DefaultTransport.(*http.Transport).Clone() 79 + proxyTransport.Proxy = http.ProxyURL(cfg.proxyURL()) 80 + proxyTransport.TLSClientConfig = &tls.Config{RootCAs: rootCAs} 81 + return proxyTransport, nil 72 82 } 73 83 74 84 // this would be nice but it logs all retries as errors which sets off alerts.
+3 -3
internal/brightdata/proxy_test.go
··· 68 68 69 69 transport, ok := retryTransport.Client.HTTPClient.Transport.(*http.Transport) 70 70 if !ok { 71 - t.Fatalf("expected wrapped base *http.Transport, got %T", retryTransport.Client.HTTPClient.Transport) 71 + t.Fatalf("expected proxy *http.Transport, got %T", retryTransport.Client.HTTPClient.Transport) 72 72 } 73 73 74 74 req, err := http.NewRequest(http.MethodGet, "https://www.example.com/products", nil) ··· 104 104 if !ok { 105 105 t.Fatalf("expected *retryablehttp.RoundTripper when proxy disabled, got %T", client.Transport) 106 106 } 107 - if retryTransport.Client.HTTPClient.Transport != nil { 108 - t.Fatalf("expected default base transport via nil transport, got %T", retryTransport.Client.HTTPClient.Transport) 107 + if retryTransport.Client.HTTPClient.Transport != http.DefaultTransport { 108 + t.Fatalf("expected default base transport, got %T", retryTransport.Client.HTTPClient.Transport) 109 109 } 110 110 } 111 111
+14 -3
internal/cache/azure.go
··· 6 6 "io" 7 7 "log" 8 8 "log/slog" 9 + "net/http" 9 10 "os" 10 11 "strings" 11 12 12 13 "github.com/Azure/azure-sdk-for-go/sdk/azcore" 14 + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" 13 15 "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" 14 16 "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob" 15 17 "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror" ··· 22 24 23 25 var _ ListCache = (*BlobCache)(nil) 24 26 25 - func NewBlobCache(container string) (*BlobCache, error) { 27 + func NewBlobCache(container string, transport http.RoundTripper) (*BlobCache, error) { 26 28 // Should come from config 27 29 accountName, ok := os.LookupEnv("AZURE_STORAGE_ACCOUNT_NAME") 28 30 if !ok { ··· 37 39 cred, err := azblob.NewSharedKeyCredential(accountName, accountKey) 38 40 if err != nil { 39 41 return nil, fmt.Errorf("failed to create shared key credential: %w", err) 42 + } 43 + if transport == nil { 44 + transport = http.DefaultTransport 40 45 } 41 46 42 47 // The service URL for blob endpoints is usually in the form: http(s)://<account>.blob.core.windows.net/ 43 - client, err := azblob.NewClientWithSharedKeyCredential(fmt.Sprintf("https://%s.blob.core.windows.net/", accountName), cred, nil) 48 + client, err := azblob.NewClientWithSharedKeyCredential(fmt.Sprintf("https://%s.blob.core.windows.net/", accountName), cred, &azblob.ClientOptions{ 49 + ClientOptions: policy.ClientOptions{ 50 + Transport: &http.Client{Transport: transport}, 51 + }, 52 + }) 44 53 if err != nil { 45 54 return nil, fmt.Errorf("failed to create blob client: %w", err) 46 55 } ··· 142 151 return EnsureCache("recipes") 143 152 } 144 153 154 + // take transport here? 145 155 func EnsureCache(container string) (ListCache, error) { 146 156 _, ok := os.LookupEnv("AZURE_STORAGE_ACCOUNT_NAME") 147 157 if ok { 148 158 slog.Info("Using Azure Blob Storage for cache", "container", container) 149 - return NewBlobCache(container) 159 + // can pas in otelhttp.NewTransport(http.DefaultTransport) but it creates a lot of noise 160 + return NewBlobCache(container, http.DefaultTransport) 150 161 } 151 162 return NewFileCache(container), nil 152 163 }
+19 -10
internal/ingredients/grading/cache.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "log/slog" 8 + "sync" 8 9 9 10 "careme/internal/ai" 10 11 "careme/internal/cache" ··· 80 81 if len(missingIngredients) == 0 { 81 82 return results, nil 82 83 } 84 + slog.InfoContext(ctx, "grading non cached", "cached", len(results), "missing", len(missingIngredients)) 83 85 84 86 gradedIngredients, err := c.grader.GradeIngredients(ctx, missingIngredients) 85 - if err != nil { 86 - return nil, err 87 - } 88 - if len(gradedIngredients) != len(missingIngredients) { 89 - return nil, fmt.Errorf("ingredient grader returned %d ingredients for %d inputs", len(gradedIngredients), len(missingIngredients)) 90 - } 91 87 88 + // might get partial results back save those. 89 + var wg sync.WaitGroup 92 90 for _, gradedIngredient := range gradedIngredients { 93 91 if gradedIngredient.Grade == nil { 94 92 return nil, fmt.Errorf("ingredient grader returned nil grade for %q", ingredientLabel(gradedIngredient)) 95 93 } 96 - key := cacheKey(c.cacheVersion + "/" + ingredientHash(gradedIngredient)) 97 - if err := c.store.Save(ctx, key, &gradedIngredient); err != nil { 98 - slog.ErrorContext(ctx, "failed to cache ingredient grade", "key", key, "ingredient", ingredientLabel(gradedIngredient), "error", err) 99 - } 100 94 results = append(results, gradedIngredient) 95 + wg.Go(func() { 96 + ctx := context.WithoutCancel(ctx) 97 + key := cacheKey(c.cacheVersion + "/" + ingredientHash(gradedIngredient)) 98 + if err := c.store.Save(ctx, key, &gradedIngredient); err != nil { 99 + slog.ErrorContext(ctx, "failed to cache ingredient grade", "key", key, "ingredient", ingredientLabel(gradedIngredient), "error", err) 100 + } 101 + }) 102 + } 103 + wg.Wait() 104 + if err != nil { 105 + return nil, err 106 + } 107 + 108 + if len(gradedIngredients) != len(missingIngredients) { 109 + return nil, fmt.Errorf("ingredient grader returned %d ingredients for %d inputs", len(gradedIngredients), len(missingIngredients)) 101 110 } 102 111 103 112 return results, nil
+11 -13
internal/ingredients/grading/manager.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "net/http" 5 6 "strings" 6 7 7 8 "careme/internal/ai" ··· 35 36 } 36 37 37 38 type multiGrader struct { 38 - grader grader 39 + grader baseGrader 39 40 } 40 41 41 - func NewManager(cfg *config.Config, c cache.ListCache) grader { 42 + func (m *multiGrader) CacheVersion() string { 43 + return m.grader.CacheVersion() 44 + } 45 + 46 + func NewManager(cfg *config.Config, c cache.ListCache, httpClient *http.Client) grader { 42 47 if cfg == nil || !cfg.IngredientGrading.Enable || strings.TrimSpace(cfg.AI.APIKey) == "" { 43 48 return rubberstamp{} 44 49 } 45 - base := ai.NewIngredientGrader(cfg.AI.APIKey, cfg.IngredientGrading.Model) 46 - return &multiGrader{ 47 - grader: newCachingGrader(base, NewStore(c)), 48 - } 50 + base := ai.NewIngredientGrader(cfg.AI.APIKey, cfg.IngredientGrading.Model, httpClient) 51 + return newCachingGrader(&multiGrader{grader: base}, NewStore(c)) 49 52 } 50 53 51 54 func (m *multiGrader) GradeIngredients(ctx context.Context, ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) { ··· 54 57 } 55 58 56 59 // we assume dedupe before thing come in here 57 - 58 60 batches := lo.Chunk(ingredients, ingredientGradeBatchSize) 59 - graded, err := parallelism.Flatten(batches, func(batch []ai.InputIngredient) ([]ai.InputIngredient, error) { 61 + // return partial results so we can cache them 62 + return parallelism.Flatten(batches, func(batch []ai.InputIngredient) ([]ai.InputIngredient, error) { 60 63 return m.grader.GradeIngredients(ctx, batch) 61 64 }) 62 - if err != nil { 63 - // will have cached these 64 - return nil, err 65 - } 66 - return graded, nil 67 65 } 68 66 69 67 func ingredientHash(ingredient ai.InputIngredient) string {
+1 -3
internal/ingredients/grading/manager_test.go
··· 103 103 func TestMultiGraderBatchesUniqueIngredientsInChunksOf30(t *testing.T) { 104 104 cacheStore := NewStore(cache.NewInMemoryCache()) 105 105 backend := &stubGradeBackend{} 106 - manager := &multiGrader{ 107 - grader: newCachingGrader(backend, cacheStore), 108 - } 106 + manager := newCachingGrader(&multiGrader{backend}, cacheStore) 109 107 110 108 ingredients := make([]ai.InputIngredient, 65) 111 109 for i := range ingredients {
+15 -13
internal/kroger/client.go
··· 32 32 expiresAt time.Time 33 33 clientID string 34 34 clientSecret string 35 + httpClient *http.Client 35 36 mu sync.Mutex 36 37 } 37 38 38 - func NewKrogerTokenManager(clientID, clientSecret string) *KrogerTokenManager { 39 + func NewKrogerTokenManager(clientID, clientSecret string, httpClient *http.Client) *KrogerTokenManager { 40 + if httpClient == nil { 41 + httpClient = http.DefaultClient 42 + } 39 43 return &KrogerTokenManager{ 40 44 clientID: clientID, 41 45 clientSecret: clientSecret, 46 + httpClient: httpClient, 42 47 } 43 48 } 44 49 ··· 61 66 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 62 67 req.SetBasicAuth(m.clientID, m.clientSecret) 63 68 64 - resp, err := http.DefaultClient.Do(req) 69 + resp, err := m.httpClient.Do(req) 65 70 if err != nil { 66 71 return "", err 67 72 } ··· 90 95 return m.token, nil 91 96 } 92 97 93 - // GetOAuth2Token fetches an access token using client credentials grant 94 - // Deprecated: use KrogerTokenManager instead 95 - func GetOAuth2Token(ctx context.Context, clientID, clientSecret string) (string, error) { 96 - tm := NewKrogerTokenManager(clientID, clientSecret) 97 - return tm.GetToken(ctx) 98 - } 99 - 100 - func newBearerTokenRequestEditor(cfg *config.Config) func(context.Context, *http.Request) error { 101 - tokenManager := NewKrogerTokenManager(cfg.Kroger.ClientID, cfg.Kroger.ClientSecret) 98 + func newBearerTokenRequestEditor(cfg *config.Config, httpClient *http.Client) func(context.Context, *http.Request) error { 99 + tokenManager := NewKrogerTokenManager(cfg.Kroger.ClientID, cfg.Kroger.ClientSecret, httpClient) 102 100 103 101 return func(editorCtx context.Context, req *http.Request) error { 104 102 token, err := tokenManager.GetToken(editorCtx) ··· 110 108 } 111 109 } 112 110 113 - func NewProductsClientFromConfig(cfg *config.Config) (*products.ClientWithResponses, error) { 114 - requestEditor := newBearerTokenRequestEditor(cfg) 111 + func NewProductsClientFromConfig(cfg *config.Config, httpClient *http.Client) (*products.ClientWithResponses, error) { 112 + if httpClient == nil { 113 + httpClient = http.DefaultClient 114 + } 115 + requestEditor := newBearerTokenRequestEditor(cfg, httpClient) 115 116 productsClient, err := products.NewClientWithResponses("https://api.kroger.com", 117 + products.WithHTTPClient(httpClient), 116 118 products.WithRequestEditorFn(products.RequestEditorFn(requestEditor)), 117 119 ) 118 120 if err != nil {
+9 -4
internal/kroger/locations.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "net/http" 6 7 7 8 "careme/internal/config" 8 9 krogerlocations "careme/internal/kroger/locations" ··· 15 16 client *krogerlocations.ClientWithResponses 16 17 } 17 18 18 - func NewLocationBackendFromConfig(cfg *config.Config) (*LocationBackend, error) { 19 - requestEditor := newBearerTokenRequestEditor(cfg) 20 - client, err := krogerlocations.NewClientWithResponses("https://api.kroger.com", 19 + func NewLocationBackendFromConfig(cfg *config.Config, httpClient *http.Client) (*LocationBackend, error) { 20 + if httpClient == nil { 21 + httpClient = http.DefaultClient 22 + } 23 + requestEditor := newBearerTokenRequestEditor(cfg, httpClient) 24 + locationsClient, err := krogerlocations.NewClientWithResponses("https://api.kroger.com", 25 + krogerlocations.WithHTTPClient(httpClient), 21 26 krogerlocations.WithRequestEditorFn(krogerlocations.RequestEditorFn(requestEditor)), 22 27 ) 23 28 if err != nil { 24 29 return nil, fmt.Errorf("create kroger locations client: %w", err) 25 30 } 26 - return &LocationBackend{client: client}, nil 31 + return &LocationBackend{client: locationsClient}, nil 27 32 } 28 33 29 34 func (b *LocationBackend) IsID(locationID string) bool {
+2 -2
internal/kroger/staples.go
··· 53 53 client *products.ClientWithResponses 54 54 } 55 55 56 - func NewStaplesProvider(cfg *config.Config) (*StaplesProvider, error) { 57 - client, err := NewProductsClientFromConfig(cfg) 56 + func NewStaplesProvider(cfg *config.Config, httpClient *http.Client) (*StaplesProvider, error) { 57 + client, err := NewProductsClientFromConfig(cfg, httpClient) 58 58 if err != nil { 59 59 return nil, err 60 60 }
+6 -1
internal/locations/storage.go
··· 6 6 "errors" 7 7 "fmt" 8 8 "log/slog" 9 + "net/http" 9 10 "sort" 10 11 "time" 11 12 ··· 26 27 locationtypes "careme/internal/locations/types" 27 28 28 29 "github.com/samber/lo" 30 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 29 31 "golang.org/x/sync/errgroup" 30 32 ) 31 33 ··· 79 81 } 80 82 81 83 ctx := context.Background() 84 + httpClient := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} 82 85 backendfactories := []locationBackendFactory{ 83 - func(context.Context) (locationBackend, error) { return kroger.NewLocationBackendFromConfig(cfg) }, 86 + func(context.Context) (locationBackend, error) { 87 + return kroger.NewLocationBackendFromConfig(cfg, httpClient) 88 + }, 84 89 func(context.Context) (locationBackend, error) { return walmart.NewClient(cfg.Walmart) }, 85 90 func(ctx context.Context) (locationBackend, error) { 86 91 return aldi.NewLocationBackendFromConfig(ctx, cfg, centroids)
+5 -3
internal/mail/mail.go
··· 30 30 "github.com/sendgrid/rest" 31 31 "github.com/sendgrid/sendgrid-go" 32 32 "github.com/sendgrid/sendgrid-go/helpers/mail" 33 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 33 34 "go.opentelemetry.io/otel" 34 35 "go.opentelemetry.io/otel/attribute" 35 36 ) ··· 73 74 } 74 75 75 76 userStorage := users.NewStorage(cache) 76 - mc := critique.NewManager(cfg, cache) 77 - ig := ingredientgrading.NewManager(cfg, cache) 77 + aiHTTPClient := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} 78 + mc := critique.NewManager(cfg, cache, aiHTTPClient) 79 + ig := ingredientgrading.NewManager(cfg, cache, aiHTTPClient) 78 80 staples, err := recipes.NewCachedStaplesService(cfg, cache, ig) 79 81 if err != nil { 80 82 return nil, fmt.Errorf("failed to create staples service: %w", err) 81 83 } 82 84 ss := recipes.StatusStore(cache) 83 - aiClient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL") 85 + aiClient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL", aiHTTPClient) 84 86 generator, err := recipes.NewGenerator(aiClient, mc, staples, ss) 85 87 if err != nil { 86 88 return nil, fmt.Errorf("failed to create recipe generator: %w", err)
+9 -2
internal/recipes/critique/manager.go
··· 4 4 "context" 5 5 "fmt" 6 6 "log/slog" 7 + "net/http" 7 8 "strings" 8 9 "sync" 9 10 10 11 "careme/internal/ai" 11 12 "careme/internal/cache" 12 13 "careme/internal/config" 14 + 15 + "go.opentelemetry.io/otel" 13 16 ) 14 17 15 18 const MinimumRecipeScore = 8 ··· 58 61 wg sync.WaitGroup 59 62 } 60 63 61 - func NewManager(cfg *config.Config, c cache.ListCache) Manager { 64 + func NewManager(cfg *config.Config, c cache.ListCache, httpClient *http.Client) Manager { 62 65 if !cfg.Gemini.IsEnabled() { 63 66 return rubberstamp{} 64 67 } 65 - crit := ai.NewCritiquer(cfg.Gemini.APIKey, cfg.Gemini.CritiqueModel) 68 + crit := ai.NewCritiquer(cfg.Gemini.APIKey, cfg.Gemini.CritiqueModel, httpClient) 66 69 return &multiCritiquer{ 67 70 critiquer: newCachingCritiquer(crit, NewStore(c)), 68 71 } ··· 72 75 return mc.critiquer.Ready(ctx) 73 76 } 74 77 78 + var tracer = otel.Tracer("careme/internal/recipes/critiques") 79 + 75 80 func (mc *multiCritiquer) CritiqueRecipes(ctx context.Context, recipes []ai.Recipe) <-chan Result { 76 81 results := make(chan Result, len(recipes)) 77 82 mc.wg.Add(len(recipes)) ··· 80 85 for _, recipe := range recipes { 81 86 localWg.Go(func() { 82 87 defer mc.wg.Done() 88 + ctx, span := tracer.Start(ctx, "critques.recipe") 89 + defer span.End() 83 90 critique, err := mc.critiquer.CritiqueRecipe(ctx, recipe) 84 91 results <- Result{ 85 92 Recipe: &recipe,
+1 -1
internal/recipes/critique/multi_test.go
··· 45 45 func TestNewServiceReturnsRubberstampWithoutGemini(t *testing.T) { 46 46 t.Parallel() 47 47 48 - svc := NewManager(&config.Config{}, cache.NewFileCache(t.TempDir())) 48 + svc := NewManager(&config.Config{}, cache.NewFileCache(t.TempDir()), nil) 49 49 50 50 results := svc.CritiqueRecipes(t.Context(), []ai.Recipe{{Title: "Weeknight Pasta"}}) 51 51 result, ok := <-results
+15
internal/recipes/generator.go
··· 16 16 17 17 "github.com/samber/lo" 18 18 "github.com/samber/lo/mutable" 19 + "go.opentelemetry.io/otel" 20 + "go.opentelemetry.io/otel/attribute" 19 21 ) 20 22 21 23 type aiClient interface { ··· 38 40 staples staplesService 39 41 statusWriter statusWriter 40 42 } 43 + 44 + var tracer = otel.Tracer("careme/internal/recipes") 41 45 42 46 func NewGenerator(aiClient aiClient, critiquer critique.Service, staples staplesService, statuses statusWriter) (*generatorService, error) { 43 47 if aiClient == nil { ··· 58 62 } 59 63 60 64 func (g *generatorService) PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 65 + ctx, span := tracer.Start(ctx, "recipes.pickawine") 66 + defer span.End() 61 67 var styles []string 62 68 for _, style := range recipe.WineStyles { 63 69 style = strings.TrimSpace(style) ··· 102 108 // if we have a response id one of the three should be true? Or did they just not care and hit try again? 103 109 if p.ResponseID != "" && (p.Instructions != "" || len(p.Saved) > 0 || len(p.Dismissed) > 0) { 104 110 slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "response_id", p.ResponseID) 111 + ctx, span := tracer.Start(ctx, "recipes.regenerate") 112 + defer span.End() 105 113 instructions := regenerateInstructions(p) 106 114 107 115 // TODO give them some sort of status. ··· 125 133 return shoppingList, nil 126 134 } 127 135 136 + ctx, span := tracer.Start(ctx, "recipes.generate") 137 + defer span.End() 128 138 slog.InfoContext(ctx, "Generating recipes for location", "location", p.String()) 129 139 ingredients, err := g.staples.FetchStaples(ctx, p) 130 140 if err != nil { ··· 141 151 mutable.Shuffle(ingredients) 142 152 143 153 instructions := []string{p.Directive, p.Instructions} 154 + 144 155 shoppingList, err := g.aiClient.GenerateRecipes(ctx, p.Location, ingredients, instructions, p.Date, p.LastRecipes) 145 156 if err != nil { 146 157 return nil, fmt.Errorf("failed to generate recipes with AI: %w", err) ··· 210 221 if g.critiquer == nil { 211 222 return shoppingList, nil 212 223 } 224 + ctx, span := tracer.Start(ctx, "recipes.critique") 225 + defer span.End() 226 + 213 227 g.writeStatus(ctx, hash, titles("Getting feeeback on these recipes:", shoppingList.Recipes)) 214 228 results := g.critiquer.CritiqueRecipes(ctx, shoppingList.Recipes) 215 229 good, garbage := critique.Split(ctx, results, critique.MinimumRecipeScore) ··· 219 233 if len(garbage) == 0 { 220 234 return shoppingList, nil 221 235 } 236 + span.SetAttributes(attribute.Bool("regenaftercrique", true)) 222 237 slog.InfoContext(ctx, "Regenerating recipes based on critique feedback:", "garbage_count", len(garbage), "good_count", len(good)) 223 238 garbageRecipes := lo.Map(garbage, func(r critique.Result, _ int) ai.Recipe { return *r.Recipe }) 224 239 g.writeStatus(ctx, hash, titles("Making adjustments to these recipes: ", garbageRecipes))
+7 -7
internal/recipes/generator_test.go
··· 423 423 } 424 424 critiquer := &captureCritiqueService{} 425 425 g := &generatorService{ 426 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 426 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 427 427 aiClient: aiStub, 428 428 critiquer: critiquer, 429 429 statusWriter: noopstatuswriter{}, ··· 525 525 } 526 526 527 527 g := &generatorService{ 528 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 528 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 529 529 aiClient: aiStub, 530 530 critiquer: critiquer, 531 531 statusWriter: noopstatuswriter{}, ··· 612 612 }, 613 613 } 614 614 g := &generatorService{ 615 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 615 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 616 616 aiClient: aiStub, 617 617 critiquer: critiquer, 618 618 statusWriter: noopstatuswriter{}, ··· 647 647 }}, 648 648 } 649 649 g := &generatorService{ 650 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 650 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 651 651 aiClient: aiStub, 652 652 critiquer: &captureCritiqueService{}, 653 653 statusWriter: noopstatuswriter{}, ··· 686 686 687 687 statuses := &statusCounter{} 688 688 g := &generatorService{ 689 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 689 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 690 690 aiClient: &sequenceAIClient{generateResponses: []*ai.ShoppingList{{ResponseID: "resp-stable", Recipes: []ai.Recipe{steady}}}}, 691 691 critiquer: &captureCritiqueService{}, 692 692 statusWriter: statuses, ··· 890 890 }, 891 891 } 892 892 g := &generatorService{ 893 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 893 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 894 894 aiClient: aiStub, 895 895 critiquer: critiquer, 896 896 statusWriter: noopstatuswriter{}, ··· 945 945 }, 946 946 } 947 947 g := &generatorService{ 948 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 948 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 949 949 aiClient: aiStub, 950 950 critiquer: critiquer, 951 951 statusWriter: noopstatuswriter{},
+19 -4
internal/recipes/staples.go
··· 8 8 "hash/fnv" 9 9 "io" 10 10 "log/slog" 11 + "net/http" 11 12 "strings" 12 13 "testing" 13 14 "time" ··· 23 24 "careme/internal/wholefoods" 24 25 25 26 "github.com/samber/lo" 27 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 28 + "go.opentelemetry.io/otel/attribute" 26 29 ) 27 30 28 31 type identityProvider interface { ··· 59 62 if err != nil { 60 63 return nil, err 61 64 } 65 + ctx, span := tracer.Start(ctx, "staples.fetchstaples") 66 + span.SetAttributes(attribute.String("backend", fmt.Sprintf("%T", provider))) 67 + defer span.End() 62 68 return provider.FetchStaples(ctx, locationID) 63 69 } 64 70 ··· 67 73 if err != nil { 68 74 return nil, err 69 75 } 76 + ctx, span := tracer.Start(ctx, "staples.getingredients") 77 + span.SetAttributes(attribute.String("backend", fmt.Sprintf("%T", provider))) 78 + defer span.End() 70 79 return provider.GetIngredients(ctx, locationID, searchTerm, skip) 71 80 } 72 81 ··· 155 164 return nil, fmt.Errorf("failed to get ingredients for staples for %s: %w", locationID, err) 156 165 } 157 166 167 + ctx, span := tracer.Start(ctx, "staples.gradeingredients") 168 + defer span.End() 158 169 graded, err := s.grader.GradeIngredients(ctx, ingredients) 159 170 if err != nil { 160 171 slog.ErrorContext(ctx, "failed to grade cached staples", "error", err) ··· 241 252 return nil, fmt.Errorf("staples provider does not support location %q", locationID) 242 253 } 243 254 255 + // should we pass in a wrapper/roundtripper 244 256 func defaultStaplesBackends(cfg *config.Config) ([]backendStaplesProvider, error) { 245 257 // should we do this per request so we get new proxies per user? https://github.com/paulgmiller/careme/issues/443 246 - httpClient, err := brightdata.NewProxyAwareHTTPClient(cfg.BrightDataProxy) 258 + brightdataClient, err := brightdata.NewProxyAwareHTTPClient(cfg.BrightDataProxy) 247 259 if err != nil { 248 260 return nil, fmt.Errorf("create bright data proxy-aware client: %w", err) 249 261 } 262 + brightdataClient.Transport = otelhttp.NewTransport(brightdataClient.Transport) 250 263 251 264 // only returns an err because it ensures a cache for reese84 tokens. 252 - albertsonsProvider, err := albertsons.NewStaplesProvider(cfg.Albertsons, httpClient) 265 + albertsonsProvider, err := albertsons.NewStaplesProvider(cfg.Albertsons, brightdataClient) 253 266 if err != nil { 254 267 return nil, fmt.Errorf("create albertsons staples provider: %w", err) 255 268 } 256 269 257 - krogerBackend, err := kroger.NewStaplesProvider(cfg) 270 + // we should not use brightdata for koger 271 + httpClient := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} 272 + krogerBackend, err := kroger.NewStaplesProvider(cfg, httpClient) 258 273 if err != nil { 259 274 return nil, fmt.Errorf("create kroger staples provider: %w", err) 260 275 } ··· 264 279 krogerBackend, 265 280 // actowiz.NewStaplesProvider(), 266 281 walmart.NewStaplesProvider(), 267 - wholefoods.NewStaplesProvider(wholefoods.NewClient(httpClient)), 282 + wholefoods.NewStaplesProvider(wholefoods.NewClient(brightdataClient)), 268 283 }, nil 269 284 } 270 285
+4 -1
internal/wholefoods/client.go
··· 145 145 baseURL = DefaultBaseURL 146 146 } 147 147 if httpClient == nil { 148 - httpClient = &http.Client{Timeout: 20 * time.Second} 148 + httpClient = http.DefaultClient 149 149 } 150 150 151 151 return &client{ ··· 157 157 // Category fetches category products and follows limit/offset pagination until 158 158 // the API returns fewer items than the requested page size. 159 159 func (c *client) Category(ctx context.Context, queryterm, store string) ([]product, error) { 160 + ctx, cancel := context.WithTimeout(ctx, time.Second*20) 161 + defer cancel() 162 + 160 163 queryterm = strings.TrimSpace(queryterm) 161 164 if queryterm == "" { 162 165 return nil, errors.New("queryterm is required")