Skip to content

DUX-8094: Re-sign Bedrock requests on retry to fix SigV4 expiry#15

Open
samuelboland wants to merge 1 commit into
mainfrom
DUX-8094-fix-bedrock-sigv4-expiry-retry
Open

DUX-8094: Re-sign Bedrock requests on retry to fix SigV4 expiry#15
samuelboland wants to merge 1 commit into
mainfrom
DUX-8094-fix-bedrock-sigv4-expiry-retry

Conversation

@samuelboland

Copy link
Copy Markdown

Problem

AWS SigV4 signatures embed a timestamp and expire after 5 minutes. When faraday-retry middleware retries a timed-out Bedrock request, it resends the original request with the stale SigV4 signature. If the retry occurs after 5 minutes, AWS rejects it with a 403 InvalidSignatureException, which gets converted to RubyLLM::ForbiddenError.

Solution

Added a resign_bedrock_retry callback to the retry middleware that re-signs Bedrock requests before each retry attempt. This hook:

  • Fires inside the retry middleware right before resending
  • Regenerates the SigV4 signature with the current timestamp
  • Only affects Bedrock provider requests

Changes

  • lib/ruby_llm/connection.rb: Added retry_block to retry middleware config and implemented resign_bedrock_retry method that detects Bedrock requests and re-signs them
  • spec/ruby_llm/connection_bedrock_retry_spec.rb: Added comprehensive test coverage for SigV4 re-signing behavior on retry, including timeout scenarios and signature validation

Agent session: https://staging.supernova.dx.appf.io/coders/93bb1211-0236-475a-be52-aaf5b95d2ea6

Bedrock requests are signed once before the first attempt. SigV4
signatures embed a timestamp and expire after 5 minutes, so
faraday-retry would resend the same stale signature on every retry,
guaranteeing a "Signature expired" error on any retry that follows a
slow first attempt. Hook faraday-retry's retry_block to recompute the
signature with a fresh X-Amz-Date before each replay. Other providers
that don't define #sign_headers are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Sam Boland <sam.boland@appfolio.com>
@kermitapp

kermitapp Bot commented Jul 13, 2026

Copy link
Copy Markdown

@samuelboland samuelboland self-assigned this Jul 13, 2026
@staging-supernova-dx-appf-io staging-supernova-dx-appf-io Bot marked this pull request as ready for review July 13, 2026 22:20
@staging-supernova-dx-appf-io staging-supernova-dx-appf-io Bot requested a review from a team as a code owner July 13, 2026 22:20
@staging-supernova-dx-appf-io

Copy link
Copy Markdown

🤖 Supernova Code Review — View trace

@samuelboland

Copy link
Copy Markdown
Author

Fix me a full review of this PR.

@staging-supernova-dx-appf-io staging-supernova-dx-appf-io 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.

Review summary

The approach is sound — intercepting retries to refresh the SigV4 signature before replay is the right place to fix the 5-minute expiry problem.

One concrete issue to address before merging:

The resign_bedrock_retry(env:, **) keyword-argument signature requires faraday-retry ≥ 2.0 (see inline comment on line 120). The gemspec currently allows >= 1, and 1.x calls retry_block with five positional arguments, which will raise ArgumentError on every retry. Bumping the gemspec minimum to >= 2.0 (or adding a compatibility shim) is needed.

Minor observation (spec):

The test manually signs the initial request in the Faraday block rather than going through the production signing path (the protocol code). The retry re-sign logic is still exercised correctly, but the asymmetry between the test's initial signing and production's initial signing could mask subtle differences in what env.body looks like at retry time. Either using provider.signed_post for the test call or adding a clarifying comment would help.


🔁 Re-run this review

# retry_block runs synchronously, still inside the retry middleware, right before the env
# is replayed, so recomputing the signature here re-signs each attempt with a fresh
# X-Amz-Date. Other providers don't define #sign_headers and are unaffected.
def resign_bedrock_retry(env:, **)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The (env:, **) keyword-argument signature only works with faraday-retry ≥ 2.0. faraday-retry 2.0 changed retry_block from five positional arguments (env, options, retries, exception, will_retry_in) to keyword arguments. With the current gemspec constraint of faraday-retry >= 1, anyone running 1.x will hit ArgumentError: wrong number of arguments (given 5, expected 0; required keyword: env) on every retry attempt, replacing the SigV4 bug with a different crash.

The gemspec minimum should be bumped to >= 2.0:

# ruby_llm.gemspec
spec.add_dependency 'faraday-retry', '>= 2.0'

request_headers = stub_timeout_then_success(connection)

body = '{"a":1}'
response = connection.post('/model/x/converse', { a: 1 }) 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.

The test manually applies sign_headers in the request block on every call. In production, the Bedrock protocol code applies the initial signature via its own signing path — the retry re-sign uses env.body as it exists at retry time (already JSON-encoded by the :json middleware). This test therefore doesn't cover the exact production signing path for the initial attempt. Consider replacing the manual sign_headers block with a production-style signed POST (e.g. provider.signed_post(...)) or adding a comment clarifying the deliberate simplification, so future readers don't confuse the test setup with a production call.

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.

2 participants