Skip to content

chipingress: fix retry policy service-config, make it configurable - #2373

Merged
jmank88 merged 8 commits into
mainfrom
fix/chipingress-retry-config
Oct 1, 2026
Merged

jmank88 merged 8 commits into
mainfrom
fix/chipingress-retry-config

Conversation

@pkcll

@pkcll pkcll commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The retry policy passed to grpc.WithDefaultServiceConfig in pkg/chipingress/client.go was a bare
retry-policy object, missing the methodConfig[].retryPolicy wrapper gRPC service config requires.
The gRPC parser silently discards unknown top-level fields via plain json.Unmarshal, so the config
parsed to a valid-but-empty result (MethodConfig == nil) with no error. grpc.NewClient
succeeded silently. This client has never performed a single gRPC-level retry.

  • Fixes the JSON shape via a typed RetryPolicy struct and a JSON builder
    (buildRetryServiceConfigJSON) instead of a hand-written literal, so this bug class cannot recur
    silently.
  • Adds WithRetryPolicy so callers can enable and tune retries without hand-writing service-config JSON.
  • Pairs any enabled retry policy with retryThrottling (10 tokens, 0.1 ratio) so a degraded server
    cannot be amplified by retries.
  • Retries are opt-in (disabled by default) so they can be rolled out incrementally. See
    Why opt-in.

Jira: INFOPLAT-19422

Usage

// Off by default: NewClient installs no retry config.
c, _ := chipingress.NewClient(addr)

// Opt in with the recommended policy (3 attempts, 100ms-1s backoff, UNAVAILABLE + RESOURCE_EXHAUSTED).
c, _ = chipingress.NewClient(addr, chipingress.WithRetryPolicy(chipingress.DefaultRetryPolicy()))

// Or tune it; ParseStatusCodes maps codes.Code.String() names from string-based config.
retryable, _ := chipingress.ParseStatusCodes([]string{"Unavailable", "ResourceExhausted"})
p := chipingress.DefaultRetryPolicy()
p.RetryableStatusCodes = retryable
c, _ = chipingress.NewClient(addr, chipingress.WithRetryPolicy(p))

Why opt-in

The policy is scoped to the whole ChipIngress service, so it also replays Publish,
PublishBatch and RegisterSchema. An UNAVAILABLE can be returned after the server has already
done the work, so a retried batch can produce duplicate events unless the caller sets the
idempotencykey extension (IdempotencyKeyAttr). Because the previous config never took effect,
turning retries on by default would have been a first-ever behavior change for every caller.

Verified from source, not assumed

  • Duration format: the grpc-go internal serviceconfig.Duration requires protobuf-JSON seconds
    strings ("0.1s"); the original "100ms" does not parse. Once methodConfig is correctly
    nested, an invalid duration now makes grpc.NewClient return a hard error instead of silently
    doing nothing - confirmed with a test.
  • Status codes: codes.Code has no JSON marshaler, so codes are emitted as bare numbers, which
    gRPC's codes.Code.UnmarshalJSON accepts. codes.Code.String() ("Unavailable") is not valid
    parser input (only upper-case names or bare numbers are), so config-by-name goes through
    ParseStatusCodes, built from String() so it can't drift. Codes above Unauthenticated are
    rejected up front.
  • Service name: read from pb.ChipIngress_ServiceDesc.ServiceName at runtime rather than
    hardcoding a copy of the string.
  • Call sites: only pkg/beholder/client.go and pkg/durableemitter/setup.go call
    chipingress.NewClient, both via variadic Opt. With retries opt-in there is no behavior change
    for existing callers; the change is additive API (RetryPolicy, RetryThrottlingPolicy,
    WithRetryPolicy, DefaultRetryPolicy, ParseStatusCodes).

Regression test

The defining property of this bug is that it produces no error, so a bare no error assertion
would not have caught it. retry_policy_test.go drives the constructed JSON through the real gRPC
service-config parser (manual resolver + ClientConn.ParseServiceConfig) and asserts MethodConfig
is actually populated. Also includes a test proving the old malformed JSON parses with zero
MethodConfig entries, a test proving "100ms" is rejected, throttling value checks, a round-trip
of every well-known status code through the parser, and a guard that retries are off by default.

Non-goals / follow-ups

  • Does not address DEADLINE_EXCEEDED failures: retries share the caller deadline, and the
    dominant failure mode in a separate production incident had already exhausted it.
  • Retry observability (per-attempt metrics, retried-call counters, suppressed/throttled retries) is
    tracked separately: INFOPLAT-19419, INFOPLAT-19420, INFOPLAT-19421.
  • Enabling retries for specific callers (beholder, durableemitter) is a follow-up once the
    idempotency story for Publish/PublishBatch is confirmed.

Test plan

  • go build ./..., go vet ./pkg/chipingress/..., gofmt -l clean
  • New tests in retry_policy_test.go pass
  • pkg/beholder/... full suite passes

@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

✅ API Diff Results - github.com/smartcontractkit/chainlink-common/pkg/chipingress

✅ Compatible Changes (5)

./ (5)
  • DefaultRetryPolicy — ➕ Added

  • ParseStatusCodes — ➕ Added

  • RetryPolicy — ➕ Added

  • RetryThrottlingPolicy — ➕ Added

  • WithRetryPolicy — ➕ Added


📄 View full apidiff report

@pkcll
pkcll marked this pull request as ready for review September 25, 2026 22:46
@pkcll
pkcll requested a review from a team as a code owner September 25, 2026 22:46
Copilot AI lite review requested due to automatic review settings September 25, 2026 22:46
@pkcll
pkcll requested a review from jmank88 September 25, 2026 22:48

Copilot AI left a comment

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.

Copilot review overview

🟡 Changes recommended

Default retries may replay side-effecting RPCs, with additional policy validation and test coverage gaps.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 High severity · 1 Medium severity · 1 Low severity

Open (4)
What changed in this PR

Fixes malformed ChipIngress gRPC retry configuration and adds typed, configurable retry policies.

Changes:

  • Corrects service-config nesting and duration encoding.
  • Adds WithRetryPolicy and retry throttling.
  • Adds parser-backed regression tests.
File Summary
pkg/​chipingress/​retry_policy.go Defines retry policies and generates service configuration JSON.
pkg/​chipingress/​retry_policy_test.go Tests generated configuration and parser behavior.
pkg/​chipingress/​client.go Applies default and custom retry policies to clients.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/chipingress/client.go Outdated
if err != nil {
return nil, fmt.Errorf("failed to build retry policy service config: %w", err)
}
grpcOpts = append(grpcOpts, grpc.WithDefaultServiceConfig(retryServiceConfig))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Retries are now opt-in: NewClient installs no retry service config unless the caller passes WithRetryPolicy, so this ships disabled and can be rolled out per-client. DefaultRetryPolicy() is exported for callers who opt in, and the WithRetryPolicy doc calls out that the policy also replays Publish/PublishBatch/RegisterSchema and that callers should set idempotencykey when enabling it.

Comment on lines +166 to +168
MethodConfig: []methodConfigJSON{
{
Name: []methodNameJSON{{Service: pb.ChipIngress_ServiceDesc.ServiceName}},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same resolution as the thread on client.go: the policy is service-wide, so it is no longer applied by default. It only takes effect when a client opts in with WithRetryPolicy, and the doc now documents the duplicate-delivery caveat. Per-method scoping (e.g. only Ping) is a possible follow-up if we want to enable it more broadly later.

Comment on lines +201 to +203
throttling := RetryThrottlingPolicy{MaxTokens: 20, TokenRatio: 0.2}
scJSON, err := buildRetryServiceConfigJSON(defaultRetryPolicy(), &throttling)
require.NoError(t, err)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The test now decodes the generated JSON and asserts retryThrottling is present with maxTokens: 20 / tokenRatio: 0.2, asserts the block is omitted when throttling is nil, and proves gRPC actually parses it by checking that maxTokens: 0 (out of range) is rejected.

Comment thread pkg/chipingress/retry_policy.go Outdated
// hand-writing a gRPC "retryPolicy" service-config JSON block, which is easy to get wrong: the
// nesting (methodConfig[].retryPolicy, not top-level), the duration encoding (protobuf JSON
// duration, e.g. "0.1s", not Go's "100ms"), and the status-code names are all silently ignored
// by gRPC's parser if malformed, rather than rejected - see the client_test.go regression test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, now points to retry_policy_test.go.

Comment thread pkg/chipingress/retry_policy.go Outdated
Comment thread pkg/chipingress/retry_policy_test.go Outdated
@pkcll
pkcll requested a review from 4of9 September 30, 2026 14:24
The retry policy passed to grpc.WithDefaultServiceConfig was a bare
retry-policy object, missing the required methodConfig[].retryPolicy
wrapper that gRPC service config requires. gRPC's parser silently
discards unknown top-level fields via plain json.Unmarshal, so the
config parsed to a valid-but-empty result (MethodConfig == nil) with
no error. grpc.NewClient succeeded silently. This client has never
performed a single gRPC-level retry.

Fixes the JSON shape via a typed RetryPolicy struct and a JSON builder
(buildRetryServiceConfigJSON) instead of a hand-written literal, so this
bug class can't recur silently. Adds WithRetryPolicy so callers can
override without hand-writing service-config JSON themselves. Pairs the
retry policy with retryThrottling (10 tokens, 0.1 ratio) so a genuinely
degraded server cannot be amplified by a newly-functioning retry policy.

Verified from source rather than assumed:
- Duration format: grpc-go's internal serviceconfig.Duration requires
  protobuf-JSON seconds strings ("0.1s"); the original "100ms" does not
  parse. Once methodConfig is correctly nested, an invalid duration now
  makes grpc.NewClient return a hard error instead of silently doing
  nothing -- confirmed with a test.
- Service name: read from pb.ChipIngress_ServiceDesc.ServiceName at
  runtime rather than hardcoding a copy of the string.
- Call sites: only pkg/beholder/client.go and pkg/durableemitter/setup.go
  call chipingress.NewClient, both via variadic Opt, so this is
  non-breaking; existing callers get the corrected default policy.

The regression test drives the constructed JSON through gRPC's real
service-config parser (via a manual resolver + ClientConn.ParseServiceConfig)
and asserts MethodConfig is actually populated -- a bare "no error"
assertion would not have caught the original bug, since the original bug
never produced an error. Also includes a test proving the old malformed
JSON parses with zero MethodConfig entries, and a test proving "100ms"
is rejected.

Does not address DEADLINE_EXCEEDED failures, and would not have changed
the root cause of a separate incident this fix was found while
investigating -- retries share the caller's deadline, and the dominant
failure mode there had already exhausted it. This closes a chronic,
unrelated data-loss gap: every UNAVAILABLE/RESOURCE_EXHAUSTED failure
that should have retried was instead an immediate permanent drop, on a
path with no persistence.
- clientConfig gains a *RetryPolicy pointer instead of a value so the
  struct stays comparable; api-diff CI reports a comparability break as
  a breaking change via the exported Opt type. A compile-time map-key
  guard now enforces comparability.
- Silence SA1019 (staticcheck) in retry_policy_test.go: grpc's
  ParseServiceConfig only returns the deprecated ServiceConfig concrete
  type, and inspecting it is the point of the regression tests.
Replace the grpcStatusCodeName switch with a canonical-names table whose
doc records why codes.Code.String() cannot be used: String() is CamelCase
("Unavailable") and even spells Canceled differently ("Canceled" vs
canonical "CANCELLED" [sic]), while the service-config parser
(codes.Code.UnmarshalJSON -> strToCode) accepts only canonical uppercase
names. Unknown codes now fail loudly from buildRetryServiceConfigJSON
with a clear message; the old numeric fallback emitted a quoted numeric
string, which the parser rejects (only bare unquoted numerics parse).

Drop the mapKeys test helper in favor of slices.Collect(maps.Keys(...)).

Add TestGRPCStatusCodeNames_RoundTripThroughParser: all 17 well-known
names round-trip through codes.Code.UnmarshalJSON back to the same code,
and String() output is rejected.
@pkcll
pkcll force-pushed the fix/chipingress-retry-config branch from b25bbc1 to 417fc14 Compare September 30, 2026 14:53
@engnke

engnke commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Looks good to me. The previous review concerns have been addressed: retries are now opt-in, the duplicate-delivery risk of service-wide retries is clearly documented, and the parser-backed tests verify the generated retry and throttling configuration. The status-code handling and follow-up test coverage also look solid. No further blocking comments from me—approved.

@jmank88
jmank88 added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 83eb230 Oct 1, 2026
36 of 37 checks passed
@jmank88
jmank88 deleted the fix/chipingress-retry-config branch October 1, 2026 18:19
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.

4 participants