Skip to content

[BUG] TransientHttpRetryHandler retries non-idempotent POSTs, so a Portainer action can run twice #273

Closed

Description

@LarsLaskowski

Summary

TransientHttpRetryHandler retries every HTTP method on 408, 429, 5xx, HttpRequestException and TimeoutException, buffering the body so it can be resent. For non-idempotent requests — the Portainer container action (POST /api/endpoints/{id}/docker/containers/{id}/{action}), the Portainer login (POST /api/auth) and the Docker Hub login (POST v2/users/login) — a 5xx/408 answer or a broken connection does not mean the request was not processed. One requested restart/stop can therefore be executed up to RetryCount + 1 times (default 3), and a login can be repeated in a way that trips account lockout or rate limits. Non-idempotent requests must only be retried when the request demonstrably was not processed.

Area

  • docs/areas/registries.md (Retry of transient HTTP failures), docs/areas/container-actions.md (Actions, Authentication)
  • Security areas (.squad/project.md): Registry and Docker Hub calls (TransientHttpRetryHandler), Container actions (PortainerClient)

Current behavior

src/DockerUpdateGuard/Infrastructure/TransientHttpRetryHandler.cs:181-237:

if (request.Content is not null)
{
    await request.Content.LoadIntoBufferAsync(cancellationToken).ConfigureAwait(false);   // line 186-189: body buffered for resend
}
...
if (IsTransientStatusCode(response.StatusCode) == false || attempt >= retryCount)       // line 201: no method check
...
catch (Exception exception) when (IsTransientException(exception) && ... )            // line 219: any HttpRequestException, also after sending
  • IsTransientStatusCode (line 86-91): 408, 429, every status >= 500.
  • IsTransientException (line 98-101): any HttpRequestException or TimeoutException — including errors raised after the request was written (e.g. response ended prematurely).
  • The handler is attached to the Docker Hub, OCI, .NET/nginx release-feed and Portainer clients (src/DockerUpdateGuard/ServiceCollectionExtensions.cs:72-101).
  • POST callers behind it: src/DockerUpdateGuard/Portainer/PortainerClient.cs:154 (login), :336 (container action), src/DockerUpdateGuard/DockerHub/DockerHubClient.cs:969 (Docker Hub login). (Docker Scout's login at DockerScoutVulnerabilityProvider.cs:226 does not use the handler.)
  • The existing test TransientHttpRetryHandlerBuffersAndRetriesRequestWithContentAsync (src/Tests/DockerUpdateGuard.Tests/TransientHttpRetryHandlerTests.cs:405) pins the current behavior with a POST .../api/auth.
  • Documented as such: docs/areas/registries.md:138 ("Every method is retried, including requests with a body"), docs/areas/container-actions.md:66-67.

Failure scenario: Portainer sits behind a reverse proxy with a 10 s upstream timeout. A restart of a slow-stopping container takes 12 s; the proxy answers 504 while Portainer/Docker carry on. The handler waits ~0.5 s and sends the restart again, then once more after the next 504: the container is restarted three times and the caller finally reports failed.

Expected behavior

Rule (recommended; the Lead may refine it in the plan, but must keep its intent):

  • Idempotent methods (GET, HEAD, OPTIONS, TRACE, PUT, DELETE) keep today's retry behavior unchanged.
  • Non-idempotent methods (POST, PATCH and any other method) are retried only when the request cannot have been processed:
    1. a transport error raised before the request was sent — HttpRequestException whose HttpRequestError is NameResolutionError, ConnectionError, SecureConnectionError or ProxyTunnelError; and
    2. HTTP 429 Too Many Requests (the server refused the request before processing it), honoring Retry-After as today.
  • Non-idempotent requests are not retried on 408, any 5xx, TimeoutException, or any HttpRequestException that may have occurred after sending (ResponseEnded, InvalidResponse, HttpProtocolError, Unknown, …). The response or error is returned to the caller unchanged, so a Portainer action ends failed with the HTTP status / error text after exactly one attempt.
  • The retry warning log for a non-retried request is not emitted; optionally a debug entry says the request was not retried because its method is not idempotent.
  • The body is buffered only when a retry of that request is possible.

Acceptance criteria

  • POST with a 500, 502, 503, 504 or 408 answer is sent exactly once and the response is returned.
  • POST failing with HttpRequestException (HttpRequestError.ConnectionError) is retried up to RetryCount times.
  • POST failing with HttpRequestException (HttpRequestError.ResponseEnded) or TimeoutException is not retried.
  • POST with 429 (with and without Retry-After) is retried as today.
  • PATCH behaves like POST; GET, PUT, DELETE behave exactly as before (existing tests unchanged).
  • A Portainer action whose action request answers 503 is executed once and reported failed (PortainerClientTests).
  • Unit tests (MSTest, naming per docs/UNIT_TESTS.md), e.g. TransientHttpRetryHandlerDoesNotRetryPostOnServerErrorAsync, TransientHttpRetryHandlerRetriesPostOnConnectionErrorAsync, TransientHttpRetryHandlerDoesNotRetryPostOnResponseEndedAsync, TransientHttpRetryHandlerRetriesPostOnTooManyRequestsAsync; the existing TransientHttpRetryHandlerBuffersAndRetriesRequestWithContentAsync is rewritten to the new rule (e.g. a POST with body retried on a connection error, with the body resent intact).
  • docs/areas/registries.md (Retry of transient HTTP failures) and docs/areas/container-actions.md (Actions) describe the new rule; the Known gaps entries for this issue (currently registries.md Open questions item 3 and container-actions.md item 3) are removed. docs/ARCHITECTURE.md is updated where it describes the retry policy.

Implementation notes

  • HttpRequestException.HttpRequestError is available on the project's target framework (net10.0); SocketsHttpHandler sets ConnectionError/NameResolutionError/SecureConnectionError for failures while establishing the connection, i.e. before any request byte was sent. Treat Unknown conservatively as "may have been sent".
  • Options considered:
    1. Recommended – method-based rule inside TransientHttpRetryHandler as above: one place, covers every current and future POST.
    2. Per-request opt-out via HttpRequestOptions set by the callers: more flexible but easy to forget on a new POST.
    3. Excluding only Portainer from the handler: would also lose the safe retries of its GETs and leave the Docker Hub login problem.
  • The Docker Hub login (DockerHubClient.cs:969) and Portainer login are effectively safe to repeat, but they are covered by the same rule on purpose (lockout/rate limiting); no exception list.
  • Scanning:RetryCount semantics, backoff, jitter, Retry-After handling and the total request timeouts stay unchanged. Retry log entries keep omitting the query string (GetRequestTarget).
  • No configuration, README or migration impact (unless README describes the retry policy — then update it).

Related


Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    and removed on Oct 18, 2026
  2. LarsLaskowski commented on Oct 24, 2026

    @LarsLaskowski
    OwnerAuthor

    Squad working record

    plan.md

    Full plan (tier standard, ACs 1–10, challenge and security sections): https://github.com/LarsLaskowski/DockerUpdateGuard/blob/04801d1823d59f7393b637a429fa2467a88ee584/specs/issue-273/plan.md

    Summary: tier standard; security areas touched: Registries and Docker Hub calls, Container actions, Logging and telemetry of external data. Rule: idempotent methods keep today's retries; every other method is retried only after a connection-phase HttpRequestError (ConnectionError, NameResolutionError, SecureConnectionError, ProxyTunnelError) or on 429. Plan challenge: 0 major, 2 minor (buffering stays unconditional; AC6 test uses a single-read body). Security plan review: first CHANGES_REQUIRED (B1: SocketsHttpHandler resends a body-less POST on a reused connection, so the Portainer action now sends an empty ByteArrayContent; B2: logging area listed), confirmed APPROVED after one revision. Decision record 0008.

    log.md
    Date Step Member Result
    2026-10-24 16:16 1 Intake Orchestrator Issue #273 read (bug, label bug, open, no comments); branch claude/bold-heisenberg-gb0vc6 reset to origin/main fe279e8
    2026-10-24 16:24 2 Plan squad-lead DONE: tier standard; plan.md + decision record 0008 (Proposed); follow-up issue proposed: PortainerClient raw ex.Message (opus/medium · 129,073 tokens · 39 tool uses · 462 s)
    2026-10-24 16:26 2 Plan challenge squad-devils-advocate OBJECTIONS 0 major, 2 minor (Content-Length when RetryCount 0 for Portainer login; AC6 test must use single-read body) (opus/medium · 49,463 tokens · 9 tool uses · 83 s)
    2026-10-24 16:27 3 Plan security review squad-security CHANGES_REQUIRED: B1 SocketsHttpHandler resends body-less action POST (content: null) on reused connection; B2 area 'Logging and telemetry of external data' not listed; non-blocking N1-N4 (opus/medium · 50,963 tokens · 10 tool uses · 131 s)
    2026-10-24 16:30 3 Plan revise squad-lead DONE: revised for DA minors + Security B1/B2: action sent with empty ByteArrayContent (AC10), buffering unchanged (AC7), area Logging added; follow-ups proposed: raw ex.Message, 429 Retry-After clamp on login (opus/medium · 69,421 tokens · 29 tool uses · 194 s)
    2026-10-24 16:31 3 Plan security delta squad-security APPROVED (B1, B2 resolved); NB1/NB2 appended to plan.md as Security notes (opus/medium · 12,036 tokens · 3 tool uses · 31 s · continued)
    2026-10-24 16:31 4 Skeleton Orchestrator skipped: plan adds no API (private/internal members only)
    2026-10-24 16:37 5 Tests first squad-tester DONE: 19 of 80 tests in the two classes fail on current code (confirmed by orchestrator); deferred/guards: AC2, AC4, AC5 connection+idempotent rows, AC6, AC7, AC8 zero-retry (sonnet/medium · 136,478 tokens · 14 tool uses · 346 s)
    2026-10-24 16:44 6 Implement squad-dev DONE: handler method rule, log 3502, Portainer empty ByteArrayContent, docs; dev-reported coverage 100% of new lines, NB2 unverifiable without real Portainer (sonnet/medium · 108,688 tokens · 15 tool uses · 415 s)
    2026-10-24 16:47 7 Code check Orchestrator Format check, Release build, tests 655+21 green, Coverage gate PASS (new 100% 29/29, overall 85.0%), Analyzer gate PASS; Sonar gate NOT CHECKED (no SONAR_TOKEN); main unchanged
    2026-10-24 16:49 8 Review round 1 squad-reviewer BLOCKING 0, NON-BLOCKING 2 (zero-retry/exhausted-retries test gap for AC8; registries.md sentence phrased backwards) (opus/medium · 62,286 tokens · 11 tool uses · 85 s)
    2026-10-24 16:50 8 Verbatim doc fix Orchestrator Applied reviewer's verbatim fix to docs/areas/registries.md (non-idempotent request always carries a body); diff contains nothing else
    2026-10-24 16:52 9 PR approval squad-lead APPROVED conditional: (1) LogNotRetried helper accepted; (2) add two AC8 guard tests via Tester delta round; (3) NB2 residual stated in PR body, no issue; (4) file both follow-up issues (opus/medium · 59,491 tokens · 10 tool uses · 81 s)
    2026-10-24 16:56 8 AC8 guard tests squad-tester DONE: two guard tests added; gates green (657+21 tests, coverage new 100%, analyzer PASS) (sonnet/medium · 3,820 tokens · 2 tool uses · 126 s · continued)
    2026-10-24 16:57 8 Review round 2 (delta) squad-reviewer APPROVE: both round-1 findings fixed, no new defect (opus/medium · 3,962 tokens · 2 tool uses · 22 s · continued)
    2026-10-24 16:57 9 PR approval Orchestrator checklist met on head cd3392e; record 0008 set Accepted, decision-check PASS
    2026-10-24 16:57 10 Follow-up issues Orchestrator Filed #315 (raw ex.Message in PortainerClient results) and #316 (429 Retry-After clamp for logins)
    2026-10-24 16:57 10 Pull request Orchestrator Working record posted as issue comment; specs/issue-273 removed; draft PR opened
    2026-10-24 16:58 12 Wrap-up Orchestrator no lessons; PR #317 opened as draft, subscribed

    Squad run metrics

    Member Model/effort Launches Tokens Tool uses Seconds
    squad-dev sonnet/medium 1 108,688 15 415
    squad-devils-advocate opus/medium 1 49,463 9 83
    squad-lead opus/medium 3 257,985 78 737
    squad-reviewer opus/medium 2 (1 continued) 66,248 13 107
    squad-security opus/medium 2 (1 continued) 62,999 13 162
    squad-tester sonnet/medium 2 (1 continued) 140,298 16 472
    Total 11 (3 continued) 685,681 144 1976
    Member Wall time (s) Not covered by launches (s) Gate runs
    Orchestrator 2,520 629 1

    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions