# Rules that make an App Store review submission safe to rerun

> If the server accepts a review request but the response is lost, a rerun is rejected as a duplicate. How DailySudoku decides what a rerun should do.

- Canonical: https://jaemyeong.com/en/blog/app-store-connect-idempotent-submission/
- Published: 2026.08.18
- Updated: 2026.10.04
- Category: IT/개발
- Tags: #iOS, #CI-CD, #fastlane, #Automation, #App Store Connect

DailySudoku is an app that gives one Sudoku puzzle per day. I build it alone and ship it on the [App Store](https://apps.apple.com/app/id1149229748), [Google Play](https://play.google.com/store/apps/details?id=so.object.sudoku), and the [web](https://dailysudoku.app/ko/). Pushing a tag runs everything from build upload to the iOS review request. When one channel fails, the design is to rerun only that channel.

That design had a gap. If the server processes a request but the client ends without receiving the response, the rerun is rejected as a duplicate. The Android side fixed the same problem earlier, and this time I added an idempotency check to iOS. What follows is based on the iOS automation on the develop branch as of August 16, 2026.

## The state names were different to begin with

To make a decision, the code has to read the current state of the version. I opened the [spaceship](https://docs.fastlane.tools/advanced/Spaceship/) implementation in the fastlane version pinned by `Gemfile.lock` and found two accessors for the state.

This is the accessor declaration in the spaceship app version model.

```ruby
attr_accessor :app_store_state      # Deprecated in App Store Connect API specification 3.3
attr_accessor :app_version_state    # ← 현행
```

The comment on the second line, 현행, means "current."

The two accessors differ in more than the name. They return different values. `READY_FOR_SALE` on the old one is `READY_FOR_DISTRIBUTION` on the current one, and `PROCESSING_FOR_APP_STORE` is `PROCESSING_FOR_DISTRIBUTION`. The filter that spaceship uses to look up versions also uses the current field. To decide based on the [app version resource in the App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi/app_store_versions), the code has to read the current accessor.

The name has a trap too. In JSON the field is `appVersionState`, but the accessor name after Ruby mapping is different. Accessing it with the camel-case name gives nil, and code that compares against nil passes the syntax check. The flow then falls into the "unknown state" branch without any error.

## When the version state alone gives the wrong answer

At first I classified `READY_FOR_REVIEW` as "submission accepted." That was wrong. The same state remains when the run stops after preparing the version and creating the submission object, right before the last API call. If this state counts as success, the rerun ends without making the actual review request.

The build needed its own check. Even with the same marketing version, the build number selected on the version can differ because of a manual submission or an earlier run. Whether the uploaded build exists and whether that build is attached to the version are two different conditions. Without separating them, the review state of a different binary is read as success for this build.

## A second axis: the submission object for the whole app

A second investigation showed that the submission tool does not reject based on the version state. It checks whether the app as a whole has a [review submission](https://developer.apple.com/documentation/appstoreconnectapi/review_submissions) in progress.

This is part of the branch that fastlane [deliver](https://docs.fastlane.tools/actions/deliver/) checks before submitting for review.

```ruby
if app.get_in_progress_review_submission(platform:)
  UI.user_error!("Cannot submit for review - A review submission is already in progress")
  # …
```

The `READY_FOR_REVIEW` state and an unfinished submission object can exist at the same time. So the version state alone cannot decide the outcome. The state of the submission object has to be read as well.

I got this wrong once more. Reducing the lookup result to "object exists or not" erases the difference between waiting, in review, and unresolved issues. A rejected version could still be judged a success because the build and the notes match. I changed the code to keep the submission object's state as it is and to apply blocking conditions first.

An enum value was also missing. Of the 15 states, I had classified only 14, and the missing one was the approved state. A rerun while waiting for manual release after approval could fail. Now a self-test walks through every enum value in the pinned gem and asserts that zero values are unclassified. If a gem update adds a new value, the verification step fails before any real submission.

## A third defect in the submission note comparison

In the test that compares submission notes per locale, I had typed the keys by hand. As a result the test exercised only the comparison function. It did not check whether my locale keys match the keys the API returns. If the spelling differs, every locale is judged missing and the whole rerun is blocked.

The error message was a problem too. The old message pointed to stale notes as the cause, which did not help when the real cause was a key mismatch. So I handle one case separately: every locale is missing, but the set of notes on the server is not empty. In that case the code prints the key list from the input and the key list from the server response.

## Four decision rules

After three rounds of fixes, I reduced the rules to four.

1. Keep the version state and the app-wide submission state as independent axes.
2. On an unknown value, fail closed and ask a person to decide.
3. If either axis shows a blocking signal, blocking wins over submitting.
4. A dry run only reports the state and does not stop because of that state.

The priority in rule 3 is fixed in the structure, not left to the order of conditional statements. Rule 4 exists because the state can change between the rehearsal and the real tag run.

## How I verified it without calling App Store Connect

The decision logic is split into pure functions that run without credentials. That makes it possible to verify with a self-test and no call to the [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi). The checks cover a table per version state, a table per submission object state, and the classification of every enum value in the gem.

There is also a guard against an implementation that ignores the second axis. Two contrast cases keep the version state the same and change only the submission object state. If they produce the same decision, the test fails. For the note comparison I used mutation. I replaced the comparison function with one that always returns false and one that always returns true, and confirmed that both the too-permissive defect and the too-strict defect are caught. I covered the idea of checking the checker with a control in [a separate post about negative controls in verification scripts](/en/blog/verification-script-negative-control/).

Looking back, one round of fixes was avoidable. A document that investigated the second axis was already in the repository, and I started without reading it. I need the habit of rereading the notes I wrote months ago.

Some things remain unverified. I have no record of reproducing the failure where the server finishes and only the response is lost. It was hard to build a way to reproduce it. What I verified is the decision logic that reads combinations of states, not the whole failure. The exhaustive enum check also covers only the values that the pinned gem knows. Whether the gem reflects the full, latest set of states on the server cannot be known until the gem is updated.
