Skip to content

Do not retry non-idempotent requests the server may have processed (fixes #43) - #44

Merged
davidnmbond merged 1 commit into
mainfrom
fix/issue-43-non-idempotent-retry
Aug 28, 2026
Merged

Do not retry non-idempotent requests the server may have processed (fixes #43)#44
davidnmbond merged 1 commit into
mainfrom
fix/issue-43-non-idempotent-retry

Conversation

@davidnmbond

Copy link
Copy Markdown
Contributor

Closes #43.

The distinction

#42 admitted 408 and 429 alongside 5xx, on the grounds that the server rejected the request without processing it. That reasoning is right, and it is exactly why it does not extend to 5xx — a 504 means the opposite of a 408: the request arrived, work began, and a proxy stopped waiting.

Method 408 / 429 5xx Client timeout
GET, HEAD, PUT, DELETE, OPTIONS, TRACE retry retry retry
POST, PATCH retry no no

RetryCount keeps its default. What changed is what is eligible for retry, so idempotent traffic — the overwhelming majority — is untouched.

Why it matters

In Magic Suite, one POST starting a two-minute AI investigation, behind a proxy with a 60-second read timeout, ran six times. The user waited six minutes for the error and watched the UI cycle through five investigations nobody asked for. A create retried after a 504 could also duplicate the record.

Deliberately not changed

HttpRequestException still retries for all methods. It is overwhelmingly a connection-level failure where nothing was processed, and narrowing it would cost real resilience for no clear safety gain. Flagging it so the omission reads as a decision.

Verification

Red-checked: with the fix reverted, exactly the four POST/5xx cases fail (4 attempts instead of 1) while the 408/429 and GET guards stay green — confirming they are genuine regression guards rather than co-dependent on the change. 722 unit tests pass, including #42's.

…ixes #43)

#42 admitted 408 and 429 alongside 5xx, reasoning that "in both cases the
server rejected the request without processing it ... so retrying is safe
even for methods that are not idempotent". That is correct for 408 and
429, and it is precisely why it does not extend to 5xx.

A 504 means the opposite of a 408: the request reached the server, the
server began work, and a proxy stopped waiting. 502 and 503 can likewise
follow an upstream accepting the request. A client-side timeout is the
strongest signal of all that work is still in flight. Repeating a POST in
any of those states duplicates the effect, or repeats expensive work whose
first result is still on its way.

Found in Magic Suite: one POST starting a two-minute AI investigation,
behind a proxy with a 60-second read timeout, ran six times. The user
waited six minutes to be told it had failed, and watched the UI cycle
through five investigations nobody had asked for.

Retry eligibility is now gated on RFC 9110 idempotency: POST and PATCH
retry only on 408/429; every other method is unchanged. RetryCount keeps
its default - what changed is what is eligible, not how many attempts.
This is the rule nginx applies for the same reason, refusing to retry a
non-idempotent request upstream without an explicit non_idempotent opt-in.

HttpRequestException is deliberately left retrying for all methods. It is
overwhelmingly a connection-level failure where nothing was processed, and
narrowing it would cost real resilience for no clear safety gain. Stated
here so the omission reads as a decision rather than an oversight.

Verified by red-check: with the fix reverted, exactly the four POST/5xx
cases fail (4 attempts instead of 1) while the 408/429 and GET guards stay
green - so those are genuine regression guards. 722 unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 12 complexity · 0 duplication

Metric Results
Complexity 12
Duplication 0

View in Codacy

AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes.

@codacy-production codacy-production 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.

Pull Request Overview

The PR correctly restricts retry behavior for non-idempotent HTTP methods (POST, PATCH) to prevent unintended side effects on the server, specifically handling 5xx errors and client-side timeouts. However, the implementation in TrySendRequestAsync has increased the method's complexity beyond recommended limits, and a logging bug was identified where the attempt count is hardcoded to 1 regardless of previous retries for 408/429 status codes. While the logic appears sound, there is a lack of unit test coverage for TaskCanceledException scenarios and PATCH requests, which are central to the PR's intent.

About this PR

  • There are no tests covering the PATCH method in the provided diff, although the logic explicitly treats it as non-idempotent. It is recommended to include PATCH in the retry test suite.
  • The logic added to handle TaskCanceledException (lines 256-260 in ODataClient.cs) to prevent retries for non-idempotent methods lacks corresponding unit tests. Please add coverage for client-side timeouts to ensure expected behavior.
1 comment outside of the diff
PanoramicData.OData.Client/ODataClient.cs

line 201 ⚪ LOW RISK
Suggestion: The method TrySendRequestAsync currently handles request execution, trace logging, and complex exception-handling logic for both HttpRequestException and TaskCanceledException. To improve maintainability, consider extracting the error-handling blocks or the trace logging into smaller helper methods such as HandleRequestException and HandleTimeoutException.

Test suggestions

  • A POST request receiving a 5xx status code (e.g., 504 Gateway Timeout) is attempted exactly once.
  • A POST request receiving a 408 Request Timeout or 429 Too Many Requests is retried up to the configured limit.
  • A GET request receiving a 5xx status code is retried up to the limit.
  • A POST request resulting in a client-side TaskCanceledException is not retried.
  • An idempotent request (e.g., GET) resulting in a client-side TaskCanceledException is retried up to the limit.
Prompt proposal for missing tests
Consider implementing these tests if applicable:
1. A POST request resulting in a client-side TaskCanceledException is not retried.
2. An idempotent request (e.g., GET) resulting in a client-side TaskCanceledException is retried up to the limit.

TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback

// all - it duplicates work that is still in flight (issue #43).
if (!IsIdempotent(request.Method))
{
LoggerMessages.RetriesExhaustedException(_logger, ex, request.RequestUri, "timed out", 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.

🟡 MEDIUM RISK

The attempt count should reflect the actual number of attempts made, even when aborting early for non-idempotent requests.

Suggested change
LoggerMessages.RetriesExhaustedException(_logger, ex, request.RequestUri, "timed out", 1);
LoggerMessages.RetriesExhaustedException(_logger, ex, request.RequestUri, "timed out", retryCount + 1);

@davidnmbond
davidnmbond merged commit 9835e20 into main Aug 28, 2026
5 checks passed
@davidnmbond
davidnmbond deleted the fix/issue-43-non-idempotent-retry branch August 28, 2026 13:12
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.

Retrying non-idempotent requests on 5xx and client timeouts repeats work the server may already have done

1 participant