Skip to content

fix(EVO-2167): retry the AI Processor call on transient failures#4

Open
pastoriniMatheus wants to merge 1 commit into
developfrom
fix/EVO-2167-ai-processor-retry
Open

fix(EVO-2167): retry the AI Processor call on transient failures#4
pastoriniMatheus wants to merge 1 commit into
developfrom
fix/EVO-2167-ai-processor-retry

Conversation

@pastoriniMatheus

@pastoriniMatheus pastoriniMatheus commented Jul 18, 2026

Copy link
Copy Markdown

EVO-2167 — retry na chamada ao AI Processor em falha transitória

Root cause

O bot-runtime fazia uma única chamada ao AI Processor (pkg/ai/service/ai_adapter.go): qualquer status ≠ 200 (401/5xx) ou erro de rede abortava o pipeline e a mensagem do cliente era descartada, sem resposta e sem reenvio — só existia AI_CALL_TIMEOUT_SECONDS. Um blip momentâneo (deploy, restart, hiccup de DB) = resposta perdida em definitivo.

resp, err := a.client.Do(httpReq)      // chamada única
...
if resp.StatusCode != http.StatusOK {  // qualquer !=200 -> erro terminal
    return nil, fmt.Errorf("pipeline.ai.status: unexpected %d from AI Processor", resp.StatusCode)
}

Correção

Arquivo Mudança
ai_adapter.go Extrai uma tentativa em doOnce() e envolve Call() num loop de retry com backoff exponencial + jitter (cap 5s). Retenta: erros de rede e 429/500/502/503/504. Não retenta: 4xx (permanente), timeout por tentativa, cancelamento do pipeline. Body construído uma vez e reusado
config.go AI_CALL_MAX_RETRIES (default 2) e AI_CALL_RETRY_BASE_MS (default 200)
main.go Passa a nova config ao NewAIAdapter

Log pipeline.ai.retry por tentativa (observabilidade).

Sinergia com EVO-2166

Com o EVO-2166, o processor passa a devolver 503 (não um 401 silencioso) em erro de infra — então este retry cobre o caso transitório de auth/infra sem reenviar 401 genuíno (chave inválida). Causa-raiz do incidente é a EVO-2141 (pool_pre_ping, já mergeada); isto é defesa em profundidade.

Testes (ai_adapter_retry_test.go, httptest contando requisições)

  • 503→200: retenta e entrega (2 chamadas)
  • 500 persistente: esgota retries (1+2 = 3 chamadas) → erro
  • 400: não retenta (1 chamada) → erro
  • erro de rede (conexão abortada) → sucesso na 2ª (2 chamadas)
  • maxRetries=0: desabilita retry (1 chamada)

Testes existentes atualizados para a nova assinatura (0 retries = comportamento single-call). Resultado local: go build ./... ✅ · go vet ./pkg/ai/... ./internal/config/... ✅ · go test ./pkg/ai/... ./internal/config/... ok (3.5s) ✅.

test/e2e/e2e_test.go já era incompatível com NewAIAdapter no develop (assinatura antiga com URL — pré-existente, alheio a esta issue) e ficou como está. O CI do repo é docker-only (sem lane de go test).

Relacionado

  • EVO-2141 (Done) — causa-raiz do pool.
  • EVO-2166 — processor devolve 5xx (não 401) em erro de infra; este PR reenvia esse 5xx.

Summary by Sourcery

Add configurable retry logic with exponential backoff for AI Processor calls to avoid dropping customer replies on transient failures.

New Features:

  • Introduce configurable AI Processor retry behavior controlled by AI_CALL_MAX_RETRIES and AI_CALL_RETRY_BASE_MS settings.

Enhancements:

  • Wrap AI Processor calls with retry logic for transient HTTP and network errors while preserving existing single-call behavior when retries are disabled.
  • Add structured logging for AI Processor retry attempts to improve observability.

Tests:

  • Add dedicated tests covering retry behavior for transient and permanent AI Processor failures and update existing adapter tests for the new constructor signature.

The bot-runtime made a single call to the AI Processor: any non-200 (401/5xx) or
network error aborted the pipeline and the customer's message was dropped with no
reply and no retry (only AI_CALL_TIMEOUT_SECONDS existed). A momentary blip —
deploy, restart, DB hiccup — meant a permanently lost answer.

- ai_adapter.go: extract a single attempt into doOnce() and wrap Call() in a retry
  loop with exponential backoff + jitter. Retryable: network errors and
  429/500/502/503/504. NOT retried: 4xx (permanent), per-attempt timeout, pipeline
  cancellation. Body is built once and reused per attempt.
- config.go: AI_CALL_MAX_RETRIES (default 2) and AI_CALL_RETRY_BASE_MS (default 200).
- main.go: wire the new config into NewAIAdapter.
- tests: 503->200 retry succeeds (2 calls); persistent 500 exhausts retries
  (1+2 calls); 400 not retried (1 call); network error then success (2 calls);
  maxRetries=0 disables retry. Existing tests updated to the new signature (0 retries).

Complements EVO-2166: the processor now returns 503 (not a silent 401) on infra
errors, so this retry covers the transient auth/infra case. Root cause of the
incident is EVO-2141 (pool_pre_ping, already merged); this is defense in depth.

Note: test/e2e/e2e_test.go was already incompatible with NewAIAdapter on develop
(pre-existing, unrelated) and is left as-is; repo CI is docker-only (no go test lane).
@sourcery-ai

sourcery-ai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements configurable retry logic with exponential backoff and jitter for AI Processor calls, wiring it through configuration and main, and adding focused tests to validate retry and non-retry scenarios while preserving existing single-call behavior when retries are disabled.

Sequence diagram for AIAdapter Call retry on transient failures

sequenceDiagram
    actor BotRuntime
    participant AIAdapter
    participant AIProcessor

    BotRuntime->>AIAdapter: Call(ctx, A2ARequest)
    loop attempts = maxRetries + 1
        AIAdapter->>AIProcessor: client.Do(httpReq)
        alt [network error]
            AIAdapter-->>AIAdapter: doOnce(ctx, url, body, req, start) returns (nil, true, err)
            opt [more retries]
                AIAdapter-->>AIAdapter: backoffDelay(attempt)
            end
        else [status 5xx/429]
            AIProcessor-->>AIAdapter: HTTP 5xx/429
            AIAdapter-->>AIAdapter: isRetryableStatus(StatusCode) = true
            opt [more retries]
                AIAdapter-->>AIAdapter: backoffDelay(attempt)
            end
        else [status 4xx/timeout/cancel/decode error]
            AIProcessor-->>AIAdapter: HTTP 4xx or other error
            AIAdapter-->>BotRuntime: error (no retry)
            AIAdapter-->>AIAdapter: break
        else [HTTP 200]
            AIProcessor-->>AIAdapter: HTTP 200
            AIAdapter-->>AIAdapter: doOnce(...) returns NormalizedResponse
            AIAdapter-->>BotRuntime: NormalizedResponse
            AIAdapter-->>AIAdapter: break
        end
    end
Loading

File-Level Changes

Change Details Files
Add retryable AI Processor call with exponential backoff, jitter, and clear separation of per-attempt behavior.
  • Extend AIAdapter to accept maxRetries and retryBaseMs and store them as fields.
  • Refactor Call into a retry loop that reuses a pre-built request body and logs each retry attempt with context fields.
  • Introduce doOnce to encapsulate a single HTTP call with per-attempt timeout and classify errors as retryable or non-retryable.
  • Add isRetryableStatus helper to retry on 429/5xx and skip retries on 4xx and decoding errors.
  • Implement backoffDelay with capped exponential backoff and 50% jitter, bounded by a 5s maxBackoff.
pkg/ai/service/ai_adapter.go
Make AI Processor retry behavior configurable via environment-backed config and wire it into the server.
  • Extend Config with AICallMaxRetries and AICallRetryBaseMs plus documentation comments for EVO-2167.
  • Load AI_CALL_MAX_RETRIES and AI_CALL_RETRY_BASE_MS from environment with defaults (2 retries, 200ms base).
  • Update main server wiring to construct the AIAdapter with timeout, max retries, and base delay from config.
internal/config/config.go
cmd/server/main.go
Adjust and extend tests to cover retry semantics and preserve previous single-call behavior when retries are disabled.
  • Update existing ai_adapter tests to use the new NewAIAdapter signature with retries disabled (maxRetries=0, baseMs=0) to keep prior semantics.
  • Add ai_adapter_retry_test with httptest servers to assert behavior on 503-then-200, persistent 500, 400, network errors, and maxRetries=0 (no retry).
pkg/ai/service/ai_adapter_test.go
pkg/ai/service/ai_adapter_retry_test.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai 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.

Hey - I've found 3 issues, and left some high level feedback:

  • The jitter implementation in backoffDelay uses the global math/rand without seeding, so every process will produce the same retry timing pattern; consider seeding once at startup or using a per-adapter rand.Source to get more realistic jitter.
  • In the retry loop, using time.After directly in the select allocates a new timer on every iteration even if the context is already cancelled; consider using time.NewTimer with a defer timer.Stop() or checking ctx.Done() before creating the timer to avoid unnecessary allocations.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The jitter implementation in backoffDelay uses the global math/rand without seeding, so every process will produce the same retry timing pattern; consider seeding once at startup or using a per-adapter rand.Source to get more realistic jitter.
- In the retry loop, using time.After directly in the select allocates a new timer on every iteration even if the context is already cancelled; consider using time.NewTimer with a defer timer.Stop() or checking ctx.Done() before creating the timer to avoid unnecessary allocations.

## Individual Comments

### Comment 1
<location path="pkg/ai/service/ai_adapter.go" line_range="227-228" />
<code_context>
+	if d <= 0 || d > maxBackoff {
+		d = maxBackoff
+	}
+	// Keep at least half the delay, add up to half more as jitter.
+	jitter := time.Duration(rand.Int63n(int64(d/2) + 1))
+	return d/2 + jitter
 }
</code_context>
<issue_to_address>
**suggestion:** Consider seeding or isolating the RNG source for jittered backoff

Using the package-level `math/rand` default source means the jitter sequence is deterministic (seeded to 1) unless something else seeds it, so all processes will see the same jitter pattern after startup. For more effective jitter, seed the RNG once at startup or use a dedicated `rand.Source`/`rand.Rand` for backoff (e.g., on `aiAdapter`).

Suggested implementation:

```golang
import (
	"math/rand"
	// existing imports...
)

// backoffDelay returns the exponential backoff for the given attempt (1-based)
// with up to 50% jitter, capped at maxBackoff.
func (a *aiAdapter) backoffDelay(attempt int) time.Duration {
	d := a.retryBaseDelay << (attempt - 1) // base * 2^(attempt-1)
	if d <= 0 || d > maxBackoff {
		d = maxBackoff
	}

	// Lazily initialize a jitter RNG specific to this adapter instance to
	// avoid using the global math/rand source and to get per-process jitter.
	if a.rng == nil {
		a.rng = rand.New(rand.NewSource(time.Now().UnixNano()))
	}

	// Keep at least half the delay, add up to half more as jitter.
	maxJitter := d / 2
	jitter := time.Duration(a.rng.Int63n(int64(maxJitter) + 1))

	return d/2 + jitter

```

```golang
type aiAdapter struct {
	timeoutSecs    int
	maxRetries     int
	retryBaseDelay time.Duration
	client         *http.Client

	// rng is a per-adapter RNG used for jittered backoff.
	rng *rand.Rand
}

```

1. The import edit above assumes a simple import block; you may need to merge the `math/rand` import into the existing `import (...)` block and remove the placeholder comment `// existing imports...`.
2. If you already have a constructor or initializer for `aiAdapter`, you could prefer initializing `rng` there instead of lazily inside `backoffDelay`, e.g. `rng: rand.New(rand.NewSource(time.Now().UnixNano())),` to avoid the `nil` check on every call.
3. If your project uses a central randomness source (for testability), you may want to inject a `rand.Source` or `*rand.Rand` into `aiAdapter` instead of creating it with `time.Now().UnixNano()` directly.
</issue_to_address>

### Comment 2
<location path="pkg/ai/service/ai_adapter_retry_test.go" line_range="97-58" />
<code_context>
+	}
+}
+
+func TestCall_RetriesOnNetworkErrorThenSucceeds(t *testing.T) {
+	var calls int32
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if atomic.AddInt32(&calls, 1) == 1 {
+			panic(http.ErrAbortHandler) // abruptly close the connection -> client network error
+		}
+		writeOK(w)
+	}))
+	defer server.Close()
+
+	adapter := aiService.NewAIAdapter(30, 2, 1)
+	resp, err := adapter.Call(context.Background(), retryReq(server.URL))
+	if err != nil {
+		t.Fatalf("expected success after network retry, got error: %v", err)
+	}
+	if resp.Content != "ok" {
+		t.Errorf("content = %q, want ok", resp.Content)
+	}
+	if got := atomic.LoadInt32(&calls); got != 2 {
+		t.Errorf("server calls = %d, want 2", got)
+	}
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test to ensure retries stop when the context is cancelled during backoff

Current tests cover retries on HTTP and network errors, but not how retries interact with `ctx.Done()` during backoff. Please add a test that triggers an initial transient failure with `maxRetries > 0`, cancels the context during the backoff sleep, and verifies that `Call` returns `ErrPipelineCancelled` and does not issue additional HTTP requests. This will ensure cancellation is honored even when a retry is pending.

Suggested implementation:

```golang
	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Errorf("server calls = %d, want 1 (400 is permanent, no retry)", got)
	}
}

func TestCall_StopsRetryWhenContextCancelledDuringBackoff(t *testing.T) {
	var calls int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&calls, 1)
		// Always return a transient error to trigger retry/backoff.
		w.WriteHeader(http.StatusServiceUnavailable)
	}))
	defer server.Close()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	adapter := aiService.NewAIAdapter(30, 3, 1)

	// Cancel the context while the adapter is in its backoff period before retrying.
	go func() {
		time.Sleep(10 * time.Millisecond)
		cancel()
	}()

	resp, err := adapter.Call(ctx, retryReq(server.URL))
	if !errors.Is(err, aiService.ErrPipelineCancelled) {
		t.Fatalf("expected ErrPipelineCancelled, got: %v", err)
	}
	if resp != nil {
		t.Fatalf("expected no response on cancellation, got: %#v", resp)
	}
	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Errorf("server calls = %d, want 1 (no additional calls after cancellation)", got)
	}
}

func TestCall_RetriesOn503ThenSucceeds(t *testing.T) {

```

To compile, ensure the test file's import block includes the required packages if they are not already present:

1. Add `time` and `errors` to the imports, e.g.:

<<<<<<< SEARCH
import (
=======
import (
	"errors"
	"time"
>>>>>>> REPLACE

(merge these into the existing import list alongside `context`, `net/http/httptest`, `sync/atomic`, etc., without duplicating any imports.)

This new test assumes `ErrPipelineCancelled` is exported from the `aiService` package and is the error returned when the context is canceled. If a different sentinel error is used in your codebase, replace `aiService.ErrPipelineCancelled` with the correct value.
</issue_to_address>

### Comment 3
<location path="pkg/ai/service/ai_adapter_test.go" line_range="88" />
<code_context>
 	defer server.Close()

-	adapter := aiService.NewAIAdapter(30)
+	adapter := aiService.NewAIAdapter(30, 0, 0)
 	resp, err := adapter.Call(context.Background(), &aiModel.A2ARequest{
 		OutgoingURL:    server.URL + "/api/v1/a2a/agent-123",
</code_context>
<issue_to_address>
**suggestion (testing):** Consider a small constructor-focused test to cover normalization of maxRetries and retryBaseMs

The new `NewAIAdapter` behavior that normalizes negative `maxRetries` to 0 and `retryBaseMs <= 0` to 200 isn’t currently covered by tests, since all callers use valid, non-zero values. Please add a focused unit test (e.g. `TestNewAIAdapter_NormalizesConfig`) that constructs adapters with `maxRetries = -1` and `retryBaseMs = 0` (or negative) and asserts that `maxRetries` and `retryBaseDelay` are normalized as expected. This will help prevent regressions in the defensive config handling.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +227 to +228
// Keep at least half the delay, add up to half more as jitter.
jitter := time.Duration(rand.Int63n(int64(d/2) + 1))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Consider seeding or isolating the RNG source for jittered backoff

Using the package-level math/rand default source means the jitter sequence is deterministic (seeded to 1) unless something else seeds it, so all processes will see the same jitter pattern after startup. For more effective jitter, seed the RNG once at startup or use a dedicated rand.Source/rand.Rand for backoff (e.g., on aiAdapter).

Suggested implementation:

import (
	"math/rand"
	// existing imports...
)

// backoffDelay returns the exponential backoff for the given attempt (1-based)
// with up to 50% jitter, capped at maxBackoff.
func (a *aiAdapter) backoffDelay(attempt int) time.Duration {
	d := a.retryBaseDelay << (attempt - 1) // base * 2^(attempt-1)
	if d <= 0 || d > maxBackoff {
		d = maxBackoff
	}

	// Lazily initialize a jitter RNG specific to this adapter instance to
	// avoid using the global math/rand source and to get per-process jitter.
	if a.rng == nil {
		a.rng = rand.New(rand.NewSource(time.Now().UnixNano()))
	}

	// Keep at least half the delay, add up to half more as jitter.
	maxJitter := d / 2
	jitter := time.Duration(a.rng.Int63n(int64(maxJitter) + 1))

	return d/2 + jitter
type aiAdapter struct {
	timeoutSecs    int
	maxRetries     int
	retryBaseDelay time.Duration
	client         *http.Client

	// rng is a per-adapter RNG used for jittered backoff.
	rng *rand.Rand
}
  1. The import edit above assumes a simple import block; you may need to merge the math/rand import into the existing import (...) block and remove the placeholder comment // existing imports....
  2. If you already have a constructor or initializer for aiAdapter, you could prefer initializing rng there instead of lazily inside backoffDelay, e.g. rng: rand.New(rand.NewSource(time.Now().UnixNano())), to avoid the nil check on every call.
  3. If your project uses a central randomness source (for testability), you may want to inject a rand.Source or *rand.Rand into aiAdapter instead of creating it with time.Now().UnixNano() directly.

if resp.Content != "ok" {
t.Errorf("content = %q, want ok", resp.Content)
}
if got := atomic.LoadInt32(&calls); got != 2 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add a test to ensure retries stop when the context is cancelled during backoff

Current tests cover retries on HTTP and network errors, but not how retries interact with ctx.Done() during backoff. Please add a test that triggers an initial transient failure with maxRetries > 0, cancels the context during the backoff sleep, and verifies that Call returns ErrPipelineCancelled and does not issue additional HTTP requests. This will ensure cancellation is honored even when a retry is pending.

Suggested implementation:

	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Errorf("server calls = %d, want 1 (400 is permanent, no retry)", got)
	}
}

func TestCall_StopsRetryWhenContextCancelledDuringBackoff(t *testing.T) {
	var calls int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&calls, 1)
		// Always return a transient error to trigger retry/backoff.
		w.WriteHeader(http.StatusServiceUnavailable)
	}))
	defer server.Close()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	adapter := aiService.NewAIAdapter(30, 3, 1)

	// Cancel the context while the adapter is in its backoff period before retrying.
	go func() {
		time.Sleep(10 * time.Millisecond)
		cancel()
	}()

	resp, err := adapter.Call(ctx, retryReq(server.URL))
	if !errors.Is(err, aiService.ErrPipelineCancelled) {
		t.Fatalf("expected ErrPipelineCancelled, got: %v", err)
	}
	if resp != nil {
		t.Fatalf("expected no response on cancellation, got: %#v", resp)
	}
	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Errorf("server calls = %d, want 1 (no additional calls after cancellation)", got)
	}
}

func TestCall_RetriesOn503ThenSucceeds(t *testing.T) {

To compile, ensure the test file's import block includes the required packages if they are not already present:

  1. Add time and errors to the imports, e.g.:

<<<<<<< SEARCH
import (

import (
"errors"
"time"

REPLACE

(merge these into the existing import list alongside context, net/http/httptest, sync/atomic, etc., without duplicating any imports.)

This new test assumes ErrPipelineCancelled is exported from the aiService package and is the error returned when the context is canceled. If a different sentinel error is used in your codebase, replace aiService.ErrPipelineCancelled with the correct value.

defer server.Close()

adapter := aiService.NewAIAdapter(30)
adapter := aiService.NewAIAdapter(30, 0, 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Consider a small constructor-focused test to cover normalization of maxRetries and retryBaseMs

The new NewAIAdapter behavior that normalizes negative maxRetries to 0 and retryBaseMs <= 0 to 200 isn’t currently covered by tests, since all callers use valid, non-zero values. Please add a focused unit test (e.g. TestNewAIAdapter_NormalizesConfig) that constructs adapters with maxRetries = -1 and retryBaseMs = 0 (or negative) and asserts that maxRetries and retryBaseDelay are normalized as expected. This will help prevent regressions in the defensive config handling.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant