Skip to content

Core: Retry requests with an Idempotency-Key on retriable errors - #17947

Open
HotSushi wants to merge 2 commits into
apache:mainfrom
HotSushi:idempotency-key-retry
Open

Core: Retry requests with an Idempotency-Key on retriable errors#17947
HotSushi wants to merge 2 commits into
apache:mainfrom
HotSushi:idempotency-key-retry

Conversation

@HotSushi

@HotSushi HotSushi commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What & why

The REST client's ExponentialHttpRequestRetryStrategy only applies the idempotent-retriable status codes (408, 500, 502, 503, 504) when the HTTP method itself is idempotent (Method.isIdempotent). All catalog mutation endpoints are POST, for which Method.isIdempotent is false, so a mutation that already carries an Idempotency-Key header — which the server guarantees is safe to replay — was not retried on 502/504, or on 503 without a Retry-After header.

This completes the client side of the idempotency support added in #14740 (spec: #14196): the client already generated and sent the key, but its own retry logic ignored it. This change lets the key the client sends actually authorize a retry.

Behavior change

Response on a POST mutation carrying an Idempotency-Key Safe to retry? Before After
408 / 500 / 502 / 504 Yes does not retry retries
503 without Retry-After Yes does not retry retries
503 with Retry-After Yes retries retries (unchanged)
429 Yes retries retries (unchanged)

Requests without an Idempotency-Key keep the previous conservative behavior — the new path only opens up when the safety contract is in place (the client attaches a key only when the server advertised idempotency-key-lifetime in the config response).

Changes

  • ExponentialHttpRequestRetryStrategy: treat a request carrying the Idempotency-Key header as retry-safe for the idempotent-retriable codes, in both the response path (shouldRetryIdempotent) and the network-exception path (retryRequest(HttpRequest, IOException, …)).
  • Added TestExponentialHttpRequestRetryStrategy cases: a keyed POST retries on {429, 503, 500, 502, 504, 408}; an un-keyed POST does not retry on {503, 500, 502, 504, 408}.

No public API changes (ExponentialHttpRequestRetryStrategy is package-private). This is a client-only change and does not modify open-api/rest-catalog*.

Testing

./gradlew :iceberg-core:test --tests "org.apache.iceberg.rest.TestExponentialHttpRequestRetryStrategy" — passing.

Note on scope

Beyond the response-code cases above, this also extends the network-exception retry path to honor the header, so a dropped connection on a keyed POST is retried for the same reason (the server dedupes on replay). This is a deliberate extension for symmetry; happy to split it into a follow-up if reviewers prefer to keep this PR to the response-code path only.

AI assistance

Drafted with AI assistance (code and test scaffolding). The logic was reviewed by the author and verified against the existing retry-strategy tests; the network-exception path extension noted above is the main area worth reviewer attention.

ExponentialHttpRequestRetryStrategy only applied the idempotent retriable
status codes (408, 500, 502, 503, 504) when the HTTP method itself was
idempotent. Mutation endpoints are POST, so a request that already carries
an Idempotency-Key header - which the server guarantees is safe to replay -
was not retried on 502/504 or on 503 without a Retry-After header.

Treat a request carrying the Idempotency-Key header as retry-safe for these
codes, in both the response and network-exception retry paths. Requests
without the header keep the previous conservative behavior.
@github-actions github-actions Bot added the core label Sep 4, 2026
@huaxingao

Copy link
Copy Markdown
Contributor

Thanks for finishing the client side of this — the problem statement matches what I'd expect, and the logic looks right.

One blocking item: this breaks TestRESTCatalog.testIdempotentCreateReplayAfterSimulated503. It passes on the merge base (8ea7d00) and fails on this branch with "Expecting code to raise a throwable" at TestRESTCatalog.java:3481.

Nothing is wrong with the production change. That test simulates a server that finalizes a create but responds 503, asserts the 503 surfaces as an exception, and then manually retries with the same key to get the replayed 200. With this change the client retries automatically and the server replays the 200, so the post() call now succeeds and the assertThatThrownBy no longer sees a throwable. The test just encodes the old expectation and needs updating.

Worth noting that this test is the strongest evidence the feature works, since it exercises the real server-side dedupe in CatalogHandlers rather than the retry strategy in isolation. Could you run ./gradlew :iceberg-core:test --tests "org.apache.iceberg.rest.*" — the PR description mentions only TestExponentialHttpRequestRetryStrategy, which is why this wasn't caught. That's the only failure among the 877 tests in that package.

Two non-blocking notes:

  1. The safety of this change rests entirely on the Idempotency-Key being identical across retry attempts, and nothing tests that. I verified it does hold today (BaseHTTPClient.post resolves the header supplier once, and HttpClient retries the same request object), but if someone later moved header resolution into a request interceptor, every retry would mint a new key and silently produce duplicate commits with no test failing. A test asserting both attempts carry the same key would pin the invariant.

  2. idempotency-key-lifetime is only null-checked to enable keys; the duration is never read, and nothing in the retry path is time-bounded. The spec puts this obligation on the client: "Clients SHOULD NOT reuse an Idempotency-Key after this window elapses." Defaults are fine (max-retries 5 is ~31s of backoff against PT30M), but getRetryInterval honors Retry-After verbatim with no cap and the retry count is configurable, so a key can be replayed past the advertised window where the server may no longer dedupe. Fine as a follow-up, just want it to be a conscious decision.

Also shouldRetryIdempotent and its "Check if the request is idempotent" comment now describe retry-safety rather than idempotency, so the name has drifted.

- Update testIdempotentCreateReplayAfterSimulated503: the client now
  auto-retries a keyed POST on 503 and the server replays the finalized
  200, so the call succeeds transparently. Assert success directly
  instead of expecting a thrown 503 followed by a manual retry.
- Add testIdempotentCreateRetryCarriesSameKey pinning the invariant that
  every transport attempt of a retried keyed POST carries the identical
  Idempotency-Key.
- Clarify the retry-safety comment in ExponentialHttpRequestRetryStrategy
  (idempotent method OR Idempotency-Key header), which no longer means
  strict HTTP idempotency.
@HotSushi

HotSushi commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for finishing the client side of this — the problem statement matches what I'd expect, and the logic looks right.

One blocking item: this breaks TestRESTCatalog.testIdempotentCreateReplayAfterSimulated503. It passes on the merge base (8ea7d00) and fails on this branch with "Expecting code to raise a throwable" at TestRESTCatalog.java:3481.

Nothing is wrong with the production change. That test simulates a server that finalizes a create but responds 503, asserts the 503 surfaces as an exception, and then manually retries with the same key to get the replayed 200. With this change the client retries automatically and the server replays the 200, so the post() call now succeeds and the assertThatThrownBy no longer sees a throwable. The test just encodes the old expectation and needs updating.

Worth noting that this test is the strongest evidence the feature works, since it exercises the real server-side dedupe in CatalogHandlers rather than the retry strategy in isolation. Could you run ./gradlew :iceberg-core:test --tests "org.apache.iceberg.rest.*" — the PR description mentions only TestExponentialHttpRequestRetryStrategy, which is why this wasn't caught. That's the only failure among the 877 tests in that package.

Two non-blocking notes:

  1. The safety of this change rests entirely on the Idempotency-Key being identical across retry attempts, and nothing tests that. I verified it does hold today (BaseHTTPClient.post resolves the header supplier once, and HttpClient retries the same request object), but if someone later moved header resolution into a request interceptor, every retry would mint a new key and silently produce duplicate commits with no test failing. A test asserting both attempts carry the same key would pin the invariant.
  2. idempotency-key-lifetime is only null-checked to enable keys; the duration is never read, and nothing in the retry path is time-bounded. The spec puts this obligation on the client: "Clients SHOULD NOT reuse an Idempotency-Key after this window elapses." Defaults are fine (max-retries 5 is ~31s of backoff against PT30M), but getRetryInterval honors Retry-After verbatim with no cap and the retry count is configurable, so a key can be replayed past the advertised window where the server may no longer dedupe. Fine as a follow-up, just want it to be a conscious decision.

Also shouldRetryIdempotent and its "Check if the request is idempotent" comment now describe retry-safety rather than idempotency, so the name has drifted.

Thanks for the review @huaxingao, pushed an update:

  • Broken test: fixed testIdempotentCreateReplayAfterSimulated503. It now expects the auto-retry to succeed instead of throwing. You're right, it just encoded the old behavior.
  • Same-key invariant: added testIdempotentCreateRetryCarriesSameKey. It asserts both attempts send the same key.
  • Comment drift: reworded to "retry-safe" instead of "idempotent".

On the key-lifetime point: you're right, the client doesn't bound retries to the advertised window today. I'll do it in a separate follow-up PR. Done properly it needs the lifetime plumbed into the retry strategy plus elapsed-time tracking, so it's a bigger change and cleaner on its own.

simulateFailureOnFirstSuccessByKey = new java.util.concurrent.ConcurrentHashMap<>();
// Records the Idempotency-Key value seen on every mutation request, in arrival order.
private final List<String> observedMutationIdempotencyKeys =
new java.util.concurrent.CopyOnWriteArrayList<>();

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.

nit: import java.util.concurrent.CopyOnWriteArrayList

.filter(k -> k.equals(key))
.collect(Collectors.toList());
assertThat(observedKeys).hasSize(2);
assertThat(observedKeys.get(0)).isNotNull().isEqualTo(observedKeys.get(1));

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.

Nit: this can't fail. The filter(k -> k.equals(key)) above already guarantees every surviving element equals key, so this compares key to key. hasSize(2) is the real check, so this line can just be dropped.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants