Skip to content

Refactor HTTP clients to use shared utilities and proper error handling - #168

Open
gshikhar2021 wants to merge 1 commit into
redhat-data-and-ai:mainfrom
gshikhar2021:http-package
Open

Refactor HTTP clients to use shared utilities and proper error handling#168
gshikhar2021 wants to merge 1 commit into
redhat-data-and-ai:mainfrom
gshikhar2021:http-package

Conversation

@gshikhar2021

@gshikhar2021 gshikhar2021 commented Apr 30, 2026

Copy link
Copy Markdown
Contributor
  • The embedding and docling clients had a lot of duplicated code for making HTTP requests - same logic for creating requests, setting headers, handling responses. This was getting messy, especially when we needed to check for specific error codes like rate limits (429) or validation errors (422)

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of document conversion validation errors with clearer diagnostic logging.
    • Embedding generation now automatically retries after rate-limit responses instead of failing immediately.
    • Improved authentication fallback when communicating with the document conversion service.
  • Refactor

    • Standardized HTTP request handling, timeouts, response processing, and error classification across document conversion and embedding services.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a common HTTP utility package to standardize request handling and error management across the codebase, refactoring the Docling and embedding clients to use these shared utilities. Feedback focuses on improving the robustness of the new HTTP package, including fixing a logic error in authorization header construction, handling nil clients, supporting the full 2xx success range, and truncating large error bodies in logs. Additionally, suggestions were made to avoid blocking reconciliation loops with sleeps and to correct JSON tags on internal client fields.

Comment thread pkg/http/client.go Outdated
Comment on lines +50 to +52
if apiKey != "" && authFormat != "" {
req.Header.Set("Authorization", fmt.Sprintf("%s %s", authFormat, apiKey))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current logic prevents the Authorization header from being set if authFormat is empty. This breaks the retry mechanism in the docling client, which attempts to send the API key without a prefix (e.g., for services that do not expect 'Bearer').

	if apiKey != "" {
		authHeader := apiKey
		if authFormat != "" {
			authHeader = fmt.Sprintf("%s %s", authFormat, apiKey)
		}
		req.Header.Set("Authorization", authHeader)
	}

Comment thread pkg/commonhttp/client.go
func Do(ctx context.Context, client *http.Client, req *http.Request) (int, []byte, error) {
logger := log.FromContext(ctx)

resp, err := client.Do(req)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the client parameter is nil, this call will cause a panic. It is safer to check for nil and use http.DefaultClient as a fallback.

Suggested change
resp, err := client.Do(req)
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)

Comment thread pkg/commonhttp/client.go
Comment on lines +76 to +81
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, body, &HTTPError{
StatusCode: resp.StatusCode,
Body: body,
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoding http.StatusOK (200) as the only success condition makes this utility less reusable. Many APIs return other 2xx status codes (e.g., 201 Created, 202 Accepted, 204 No Content) to indicate success. Checking for the 2xx range is more robust for a shared utility.

Suggested change
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, body, &HTTPError{
StatusCode: resp.StatusCode,
Body: body,
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, body, &HTTPError{
StatusCode: resp.StatusCode,
Body: body,
}
}

Comment thread pkg/http/errors.go Outdated
}

func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, string(e.Body))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Including the full response body in the error string can lead to excessively large log entries if the API returns a large payload (e.g., a large HTML error page). This can impact performance and log storage efficiency. Consider truncating the body.

Suggested change
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, string(e.Body))
bodyStr := string(e.Body)
if len(bodyStr) > 1024 {
bodyStr = bodyStr[:1024] + "... (truncated)"
}
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, bodyStr)
References
  1. Avoid logging a potentially large list of items to prevent excessively large log entries, which can impact performance and log storage efficiency. Instead, log a summary such as the count of items.

logger.Info("usage limit exceeded, will retry after 10 seconds", "batchStart", batchStart, "batchEnd", batchEnd)
if err != nil && commonhttp.IsStatusTooManyRequests(err) {
logger.Info("rate limit exceeded (429), will retry after 5 seconds", "batchStart", batchStart, "batchEnd", batchEnd)
time.Sleep(5 * time.Second)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using time.Sleep inside a reconciliation loop blocks the worker thread and prevents other resources from being processed. In controller-runtime, it is recommended to return ctrl.Result{RequeueAfter: ...} to handle rate limiting or temporary failures. However, since this is inside a batch loop, you would need to persist the current progress in the CR status to resume correctly after a requeue.

Comment thread pkg/docling/client.go
}

type Client struct {
Client *http.Client `json:"doclingclient"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The *http.Client field should not have a json tag unless you specifically intend to serialize the client's internal state, which is usually not desired and often fails to produce useful output. If the Client struct is marshaled to JSON, this field should be ignored.

Suggested change
Client *http.Client `json:"doclingclient"`
Client *http.Client "json:\"-\""

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Shared HTTP request creation, execution, timeout configuration, and typed status errors are added. Docling and embedding clients use the shared utilities, while controllers classify Docling 422 responses and retry embedding batches after 429 responses.

Changes

HTTP error handling

Layer / File(s) Summary
Shared HTTP contracts and execution
pkg/commonhttp/*
Adds shared timeout, request creation, response execution, typed HTTP errors, and status classifiers.
Docling client request integration
pkg/docling/client.go
Routes Docling requests through shared HTTP helpers, retries 403 responses with an alternate authorization format, and unmarshals response bytes directly.
Embedding client request integration
pkg/embedding/client.go
Uses the shared timeout, request construction, and HTTP execution helpers for embedding generation.
Controller status-specific handling
internal/controller/documentprocessor_controller.go, internal/controller/vectorembeddingsgenerator_controller.go
Logs Docling validation errors for 422 responses and retries the same embedding batch after 429 responses.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • Issue 178: Both changes update HTTP 429 handling and embedding batch retry behavior in the vector embeddings controller.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main refactor across HTTP clients and the new shared error-handling utilities.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/controller/vectorembeddingsgenerator_controller.go (1)

221-238: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Unbounded, blocking retry on 429 can hang the reconcile.

On rate-limit, the loop synchronously sleeps 5s and retries with no maximum attempt count and no ctx.Done() check. If the embedding API keeps returning 429, this blocks the reconcile goroutine (and a controller-runtime worker) indefinitely instead of returning and letting the controller requeue with backoff; it also ignores reconciler/context cancellation during the sleep.

🔧 Proposed fix: bound retries and respect context cancellation
+	const maxRateLimitRetries = 5
+	rateLimitRetries := 0
 	for batchStart := 0; batchStart < len(texts); batchStart += batchSize {
 		batchEnd := min(batchStart+batchSize, len(texts))
 		batch := texts[batchStart:batchEnd]

 		logger.Info("processing batch", "batchStart", batchStart, "batchEnd", batchEnd, "batchSize", len(batch))
 		embeddingResult, err := embeddingClient.GenerateEmbeddings(ctx, batch, encodingFormat)
 		if err != nil && commonhttp.IsStatusTooManyRequests(err) {
+			rateLimitRetries++
+			if rateLimitRetries > maxRateLimitRetries {
+				return false, fmt.Errorf("exceeded max retries after rate limiting: %w", err)
+			}
 			logger.Info("rate limit exceeded (429), will retry after 5 seconds", "batchStart", batchStart, "batchEnd", batchEnd)
-			time.Sleep(5 * time.Second)
+			select {
+			case <-ctx.Done():
+				return false, ctx.Err()
+			case <-time.After(5 * time.Second):
+			}
 			batchStart -= batchSize
 			continue
 		} else if err != nil {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/vectorembeddingsgenerator_controller.go` around lines 221
- 238, Bound the 429 retry path in the batch loop around GenerateEmbeddings by
limiting attempts and returning the error once the retry budget is exhausted,
allowing reconciliation to requeue with backoff. Replace the blocking time.Sleep
with a context-aware wait that exits promptly when ctx.Done() is triggered,
while preserving normal batch advancement and non-429 error handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/docling/client.go`:
- Around line 159-187: Update the 403 fallback in createDoclingRequest so the
retry request retains c.ClientConfig.Key by manually setting its Authorization
header to the raw API key after commonhttp.CreateHTTPRequest returns. Keep the
existing retry condition, request creation, and error handling unchanged.

In `@pkg/http/client.go`:
- Around line 50-52: Update the Authorization-header logic in CreateHTTPRequest
to set the header whenever apiKey is non-empty, using the formatted auth scheme
when authFormat is provided and the raw API key when it is empty. Preserve the
existing formatted Bearer-style behavior while enabling the fallback call from
the docling client.

In `@pkg/http/errors.go`:
- Around line 31-33: Update HTTPError.Error() to omit the complete
server-controlled Body from its returned message, using only the status code or
a bounded sanitized preview. Keep Body available for explicitly controlled
diagnostics, while preserving the existing HTTPError formatting context.

---

Outside diff comments:
In `@internal/controller/vectorembeddingsgenerator_controller.go`:
- Around line 221-238: Bound the 429 retry path in the batch loop around
GenerateEmbeddings by limiting attempts and returning the error once the retry
budget is exhausted, allowing reconciliation to requeue with backoff. Replace
the blocking time.Sleep with a context-aware wait that exits promptly when
ctx.Done() is triggered, while preserving normal batch advancement and non-429
error handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c173e0b9-5021-4581-9f01-95ec8887ed1a

📥 Commits

Reviewing files that changed from the base of the PR and between abd4ce9 and 0ed8be6.

📒 Files selected for processing (6)
  • internal/controller/documentprocessor_controller.go
  • internal/controller/vectorembeddingsgenerator_controller.go
  • pkg/docling/client.go
  • pkg/embedding/client.go
  • pkg/http/client.go
  • pkg/http/errors.go

Comment thread pkg/docling/client.go
Comment on lines +159 to +187
func (c *Client) createDoclingRequest(ctx context.Context, method, endpoint string, payload []byte) ([]byte, error) {
logger := log.FromContext(ctx)
client := &http.Client{
Timeout: 15 * time.Second,
}

req, err := c.createHTTPRequest(ctx, method, endpoint, payload, "Bearer %s")
logger.Info("sending request to docling service", "url", endpoint)

req, err := commonhttp.CreateHTTPRequest(ctx, method, endpoint, payload, "Bearer", c.ClientConfig.Key)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

logger.Info("sending request to docling service", "url", endpoint)
resp, err := client.Do(req)
statusCode, body, err := commonhttp.Do(ctx, c.Client, req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}

if resp.StatusCode == http.StatusForbidden && c.ClientConfig.Key != "" {
req, err = c.createHTTPRequest(ctx, method, endpoint, payload, "%s")
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err = client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
// If we get 403 with Bearer auth, try without Bearer prefix
if statusCode == http.StatusForbidden && c.ClientConfig.Key != "" {
logger.Info("retrying with raw API key auth", "url", endpoint)
req, err = commonhttp.CreateHTTPRequest(ctx, method, endpoint, payload, "", c.ClientConfig.Key)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
_, body, err = commonhttp.Do(ctx, c.Client, req)
if err != nil {
return nil, err
}
return body, nil
}
return nil, err
}

if resp.StatusCode != http.StatusOK {
logger.Error(errors.New("received non-200 OK response from endpoint"),
"docling request returned non-200 status",
"statusCode", resp.StatusCode,
"url", endpoint)
return nil, fmt.Errorf("failed to process request: status code %d", resp.StatusCode)
}

return resp.Body, nil
return body, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

403-retry silently drops the API key instead of sending it without the Bearer prefix.

commonhttp.CreateHTTPRequest only sets the Authorization header when both authFormat and apiKey are non-empty:

if apiKey != "" && authFormat != "" {
    req.Header.Set("Authorization", fmt.Sprintf("%s %s", authFormat, apiKey))
}

Calling it here with authFormat="" on the 403-retry path (line 174) therefore produces a request with no Authorization header at all, not the intended "raw API key auth". The retry silently sends an unauthenticated request, defeating the purpose of the fallback and likely resulting in a confusing second failure (401/403) that masks the real auth-format issue.

🐛 Proposed fix: set the header manually after creating the request
 		if statusCode == http.StatusForbidden && c.ClientConfig.Key != "" {
 			logger.Info("retrying with raw API key auth", "url", endpoint)
 			req, err = commonhttp.CreateHTTPRequest(ctx, method, endpoint, payload, "", c.ClientConfig.Key)
 			if err != nil {
 				return nil, fmt.Errorf("failed to create request: %w", err)
 			}
+			req.Header.Set("Authorization", c.ClientConfig.Key)
 			_, body, err = commonhttp.Do(ctx, c.Client, req)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/docling/client.go` around lines 159 - 187, Update the 403 fallback in
createDoclingRequest so the retry request retains c.ClientConfig.Key by manually
setting its Authorization header to the raw API key after
commonhttp.CreateHTTPRequest returns. Keep the existing retry condition, request
creation, and error handling unchanged.

Comment thread pkg/http/client.go Outdated
Comment thread pkg/commonhttp/errors.go
- The embedding and docling clients had a lot of duplicated code for making
  HTTP requests - same logic for creating requests, setting headers, handling
  responses. This was getting messy, especially when we needed to check for
  specific error codes like rate limits (429) or validation errors (422)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/embedding/client.go`:
- Around line 53-58: Restore or retain the exported embedding.HTTPClientTimeout
compatibility alias alongside NewHTTPClient, mark it deprecated, and ensure it
maps to the shared HTTP client timeout value so existing downstream references
continue to compile.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5eb4558e-773c-4632-b5ca-fb642cc3d90a

📥 Commits

Reviewing files that changed from the base of the PR and between 0ed8be6 and 5e23d0a.

📒 Files selected for processing (6)
  • internal/controller/documentprocessor_controller.go
  • internal/controller/vectorembeddingsgenerator_controller.go
  • pkg/commonhttp/client.go
  • pkg/commonhttp/errors.go
  • pkg/docling/client.go
  • pkg/embedding/client.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/controller/vectorembeddingsgenerator_controller.go
  • internal/controller/documentprocessor_controller.go
  • pkg/docling/client.go

Comment thread pkg/embedding/client.go
Comment on lines 53 to 58
func NewHTTPClient(config *HTTPClientConfig) *HTTPClient {
return &HTTPClient{
Client: &http.Client{
Timeout: HTTPClientTimeout,
Timeout: commonhttp.HTTPClientTimeout,
},
Config: config,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files | rg '(^|/)pkg/embedding/|(^|/)commonhttp/' || true

echo '--- pkg/embedding/client.go ---'
cat -n pkg/embedding/client.go | sed -n '1,180p'

echo '--- search for HTTPClientTimeout ---'
rg -n 'HTTPClientTimeout' .

Repository: redhat-data-and-ai/unstructured-data-controller

Length of output: 4879


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n pkg/commonhttp/client.go | sed -n '1,120p'

Repository: redhat-data-and-ai/unstructured-data-controller

Length of output: 3025


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git diff --unified=0 -- pkg/embedding/client.go

Repository: redhat-data-and-ai/unstructured-data-controller

Length of output: 185


Preserve embedding.HTTPClientTimeout as a compatibility alias
pkg/embedding is public API, so removing the exported timeout constant will break downstream builds. Keep a deprecated alias here, or make the breaking change explicit in a version bump/release note.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/embedding/client.go` around lines 53 - 58, Restore or retain the exported
embedding.HTTPClientTimeout compatibility alias alongside NewHTTPClient, mark it
deprecated, and ensure it maps to the shared HTTP client timeout value so
existing downstream references continue to compile.

Comment on lines 226 to +232
embeddingResult, err := embeddingClient.GenerateEmbeddings(ctx, batch, encodingFormat)
if err != nil {
if strings.Contains(err.Error(), "status 429") {
logger.Error(err, "embedding API rate limited (429), will retry on next reconciliation", "file", chunksFilePath, "batchStart", batchStart)
} else {
logger.Error(err, "failed to generate embeddings for batch", "file", chunksFilePath, "batchStart", batchStart, "batchEnd", batchEnd)
}
if err != nil && commonhttp.IsStatusTooManyRequests(err) {
logger.Info("rate limit exceeded (429), will retry after 5 seconds", "batchStart", batchStart, "batchEnd", batchEnd)
time.Sleep(5 * time.Second)
batchStart -= batchSize
continue
} else if err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think just returning the error and let the controller's runtime work queue requeue it

 if err != nil && commonhttp.IsStatusTooManyRequests(err) {
      logger.Info("rate limited (429), will retry on next reconciliation",
          "batchStart", batchStart, "batchEnd", batchEnd)
      return false, err
  } else if err != nil {
      logger.Error(err, "failed to generate embeddings for batch",
          "batchStart", batchStart, "batchEnd", batchEnd)
      return false, err
  }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants