Skip to content

fix(s3compatsigv4): ストレージ側エラーの異常系ハンドリングを全面改善(クォータ超過の明示化とコミット成否不明時の案内) - #98

Open
tishin-endou wants to merge 6 commits into
RCOSDP:developfrom
tishin-endou:fix/s3compatsigv4-quota-handling
Open

tishin-endou wants to merge 6 commits into
RCOSDP:developfrom
tishin-endou:fix/s3compatsigv4-quota-handling

Conversation

@tishin-endou

@tishin-endou tishin-endou commented Aug 4, 2026 •

Copy link
Copy Markdown

Purpose

S3CompatSigV4 の機関ストレージと外部ストレージ(アドオン)の双方で、ストレージ側の
クォータを超過したとき、アップロードが失敗したことも、その原因も利用者に伝わらない
問題を修正します。

原因は独立した4系統でした。

  1. エラーの握りつぶし — _chunked_upload() がストレージの実エラーを捨て、常に
    原因不明の HTTP 500 に置き換えていた
  2. abort 成否判定の反転 — 成功時に「一時パーツの削除に失敗した」と誤警告し、
    失敗時には警告を出していなかった(残存パーツの有無を正反対に伝えていた)
  3. 未ハンドリング経路 — contiguous 経路は生 XML がメッセージになり判読不能。
    接続断(aiohttp.ClientError)が未捕捉。セッション作成が try 外
  4. 成功ステータスで返る異常応答 — CompleteMultipartUpload は失敗を
    HTTP 200 + <Error> で返す。何もコミットされていないのに「成功」を返していた

4 を塞ぐと、commit が失敗したとき「オブジェクトが存在するのかしないのか」を
どう伝えるか
という設計判断が必要になります。これが本 PR の後半の主題です。

設計: コミット成否の3値モデル

値 意味 注記
NOT_COMMITTED エラーコードが確定的な拒否を保証する 付けない
UNKNOWN 成否を保証できない(不定コード / 応答不読 / 接続断) 付ける
(成功) 2xx + 正常ボディ 該当なし
  • 判定は S3 API 仕様のエラーコード(S3 互換ストレージが実装する共通語彙)だけで
    行い、HTTP ステータス階級や輸送経路には依存させません。評価順は
    まずクォータ判定、次に確定拒否の分類表です。分類表に載らないコードと
    ベンダー固有コードは、すべて UNKNOWN(安全側)に倒します
    (過剰注記は許容、誤断定は不可)
  • 分類表の9件のうち、実機の commit 経路で確定拒否を確認できているのは
    EntityTooSmall の1件のみ
    で、残る8件は S3 仕様と MinIO のエラー定義に基づきます。
    表外を UNKNOWN に倒す設計は、この未検証性を前提にしたものです
    (表が誤っていても、誤りは「注記が過剰に出る」側にしか倒れません)
  • 前提として commit の POST はソケット上でちょうど1回であることが必要です。
    担保は2つで、retry=0 が core の再送を、allow_redirects=False が aiohttp 3.6.2
    既定の 307/308 自動再 POST を止めます。片方でも外すと分類表が無効になるため
    (実ソケット実測で posts=2 / stored=True / 注記なし = 誤断言が再現)、
    どちらも外すとテストが落ちるようにしてあります

Changes

変更範囲は 3 ファイルのみです(waterbutler/providers/s3compatsigv4/provider.py /
同 settings.py / tests/providers/s3compatsigv4/test_provider.py)。

  • 既知のクォータコード(QuotaExceeded / XMinioAdminBucketQuotaExceeded /
    XMinioStorageFull)を 507 + 対処方法に変換。応答自体が 507 ならコードによらず
    容量超過として扱う。判別できない場合も生の本文は返しません(Resource パスや
    署名付き URL が利用者に露出するため)。容量超過は is_user_error=True として
    Sentry の 5xx アラート経路から外します
  • 接続断・全体タイムアウトは 502。<Error> があれば分類できなくても 502 で raise
    (fail-closed)。raise 経路で漏れていた resp.release() も塞ぎ、接続リークを解消
  • 注記判定は _commit_outcome_note という単一の純粋関数に集約し、クォータ分岐を
    先に評価。abort の NoSuchUpload は鵜呑みにせず ListParts で確認します
  • ログに生の応答本文が出るのは1箇所のみ・512 バイトで切ります
  • QUOTA_EXCEEDED_ERROR_CODES は provider config で拡張可能。不正な JSON でも
    import 時に落とさず(落とすと s3compatsigv4 だけが全リクエストに 404 を返す)、
    警告を出して既定値に倒します

6 コミットに整理してあり、どれを単独でチェックアウトしても
tests/providers/s3compatsigv4/ が全件 PASS し flake8 も通ります(git bisect 可能)。

# SHA 内容 テスト数
1 f77db367 abort 反転修正 73
2 f55609fe 応答本文パーサ + 507 マッピング 103
3 064df08b 502 + 設定堅牢化 + 3値モデル + コメント整理 371
4 424dcd4a DeleteObjects 部分失敗ログ + botocore 例外ラップ 375
5 096f2ac7 テスト統合(74変異の被覆集合で冗長テスト削除) 152
6 4589eaa2 EAFP 書き直し + traceback 保全(署名除去付き) + 到達不能ガード削除 154

テストは変異テストの被覆集合を用いて機械的に削減し、
コミット 5 時点で 74 種 71 killed / 3 documented equivalent / 0 mismatches で
削減前と完全一致。コミット 6 の EAFP 書き直し + 到達不能ガード削除(S10 対象コード消滅)
により 73 種 69 killed / 4 documented equivalent / 0 mismatches。
develop 由来の 70 テスト関数はすべて不変です(詳細は FREEZE_REPORT.md §13, §14)。

Side effects

  • 返却ステータスが変わります: クォータ超過 500 → 507、接続断・タイムアウト
    500 → 502、200 + <Error> / UploadId 欠落は成功扱い → 502
  • commit 失敗時に注記(「アップロードは完了している可能性があります」)が付くことが
    あります。設計上、実際にはコミットされていないケースでも付きえます(誤断定を避けるため)
  • 正当に 307 を返すストレージ構成では commit が失敗します。ただし返るのは
    「UNKNOWN + 注記」か「分類表どおりの NOT_COMMITTED」で、誤断言ではありません
  • 変更は s3compatsigv4 配下に限定しており、他プロバイダには影響しません

QA Notes

CI / ユニットテスト

6 コミットそれぞれを単独でチェックアウトして pytest と flake8 を実行し、6/6 で
失敗 0 件
を確認しています。pinned 環境(py3.6 / aiohttp 3.6.2)での全件実行:
2003 passed / 10 skipped / 0 failed。

実機 E2E(staging2 / play.min.io)

チケット番号
https://redmine.devops.rcos.nii.ac.jp/issues/63206?issue_count=3&issue_position=1&next_issue_id=59994

run-20260920-105903。バケットは e2e-quota-59774f91(機関ストレージ)と
e2e-addon-59774f91(アドオン)、いずれも hard quota 10MiB。

確認項目 結果
E-4 機関ストレージモードで超過 507。UI に growl「ストレージが不十分なため、ファイルをアップロードできません。」
E-4 外部ストレージ(アドオン)モードで超過 507。S-2 とステータス・growl 文言がバイト一致。fangorn.js の 507 専用分岐はこの文言を xhr.status === 507 のときだけ出すため、文言一致が 507 到達の独立した裏付けになる
E-5 chunked 経路で超過 507(size=129,000,000 / takes_chunked_path=true / is_user_error=true)。mc ls --incomplete の出力なし = 未完了マルチパートの残留なし。なお UI 経路の chunked 超過は 900 秒無応答となったため、本 run ではプロバイダ直叩きハーネスで取得しています(無応答そのものは本 PR と独立した WaterButler 側の欠陥。スコープ外 4)
E-6 空きを確保して再アップロード OK。200 PUT .../osfstorage/... とバケット上のオブジェクトで確認
実機のエラーコードと既定値の突合 採取コードは XMinioAdminBucketQuotaExceeded。settings.py の既定値に既存のため更新不要
UI へのエラー表示 両モードとも上記 growl。スクリーンショット上に生 XML・署名付き URL の露出なし

E2E 実施 SHA と以後の変更

上表の E2E は 96b3af61 に対して実施しています(デプロイ実施者が Jenkins ビルドログで
checkout Revision を目視確認)。96b3af61 は squash 前の 6 コミット目(3値モデル)にあたり、
現在のコミット 3(064df08b)の機能と同一です。

以後のコミット 4〜6 の変更と E2E への影響:

コミット 変更内容 E2E 経路への影響
4 424dcd4a DeleteObjects 部分失敗ログ + botocore 例外ラップ なし(削除経路のログ強化のみ。E2E はアップロード経路を検証)
5 096f2ac7 テスト統合(production code 変更なし) なし(テストファイルのみ)
6 4589eaa2 EAFP 書き直し、_log_exception ヘルパ追加(traceback 保全 + 署名除去)、到達不能ガード削除 + テスト 2 件追加、what コメント削除 動作同一(EAFP は構造変更のみ。到達不能ガード削除は xmltodict の strip_whitespace=True 既定により .strip() が先に AttributeError になるため本番で到達しない。73 変異テストで 69 killed / 4 equivalent / 0 mismatches を確認)

スコープ外(別チケット推奨)

  1. abort 反転バグが他プロバイダにも存在 — s3/provider.py:276 /
    s3compat/provider.py:377(s3compatinstitutions にも波及)。本 PR と同じ1行
  2. 「容量不足」が s3compatinstitutions では 406、本 PR では 507 になる二重体系 —
    RDM フロントエンドの分岐確認が必要
  3. 利用者向け文言4定数の日本語化を fangorn 層で行う — WB 側は英語のまま維持
    (同一レスポンス内の言語混在を避けるため4定数まとめて扱う必要があり、
    表示層の設計判断はプロバイダ層の責務ではない)
  4. chunked アップロードの無応答(tornado stream_request_body とソケットペアの
    デッドロック)— 本 PR の修正対象外。E2E で踏んだため記録として残す
  5. その他(boto3 delete_objects の例外未捕捉 / download の生 XML メッセージ /
    _chunked_upload の CancelledError 未捕捉 / _parse_s3_error_body のサイズ上限 /
    docs ページ / 冪等でない S3 操作のリトライ禁止)

後続で提出予定の関連 PR

同一リリース判定への同乗を希望します。

  • 削除異常系のログ強化
  • 接続断処理の横展開

Deployment Notes

特別な設定は不要です。ベンダー独自のクォータ超過コードがある場合のみ provider config で
S3COMPAT_PROVIDER_CONFIG_QUOTA_EXCEEDED_ERROR_CODES を拡張してください。

@tishin-endou
tishin-endou force-pushed the fix/s3compatsigv4-quota-handling branch from dcb370b to ee267a6 Compare September 12, 2026 11:08
@tishin-endou tishin-endou changed the title fix(s3compatsigv4): ストレージ側クォータ超過時の異常系ハンドリング ストレージ側エラーの異常系ハンドリングを全面改善(クォータ超過の明示化とコミット成否不明時の案内) Sep 16, 2026
@tishin-endou
tishin-endou force-pushed the fix/s3compatsigv4-quota-handling branch from 59a6a13 to 96b3af6 Compare September 16, 2026 12:46
tishin-endou added a commit to tishin-endou/RDM-waterbutler that referenced this pull request Sep 20, 2026
…notes (repo convention). Comment/docstring-only change, AST-verified identical. Full rationale preserved in PR RCOSDP#98 description and per-commit messages
@tishin-endou tishin-endou changed the title ストレージ側エラーの異常系ハンドリングを全面改善(クォータ超過の明示化とコミット成否不明時の案内) S3CompatSigV4アドオンのストレージ側エラーの異常系ハンドリングを全面改善(クォータ超過の明示化とコミット成否不明時の案内) Sep 24, 2026
@tishin-endou tishin-endou changed the title S3CompatSigV4アドオンのストレージ側エラーの異常系ハンドリングを全面改善(クォータ超過の明示化とコミット成否不明時の案内) fix(s3compatsigv4): ストレージ側エラーの異常系ハンドリングを全面改善(クォータ超過の明示化とコミット成否不明時の案内) Sep 24, 2026
@tishin-endou
tishin-endou marked this pull request as ready for review September 24, 2026 06:24
tishin-endou added a commit to tishin-endou/RDM-waterbutler that referenced this pull request Sep 24, 2026
CompleteMultipartUpload is not idempotent.  A re-send after the first attempt
succeeded meets a consumed UploadId and comes back `NoSuchUpload`, so whatever
error code is observed belongs to the last attempt and says nothing about the
upload.  Two separate mechanisms can re-send it and each needs its own stop.

Measured on the current code:

  - core's retry loop sends the commit 3 times on 408 / 502 / 503 / 504
    (`retry` defaults to 2, `retry_on` is {408, 502, 503, 504}).
  - aiohttp follows a 307/308 itself, below core's retry budget.  The new
    `commit_server` test records ['/first', '/second'] -- the commit body is
    re-POSTed to the redirect target, which answers `SignatureDoesNotMatch`.
    This is the first measurement of the redirect path for s3; `aiohttpretty`
    injects above `ClientSession._request` and cannot reach it, so the
    fixture from s3compatsigv4 (PR RCOSDP#98) is ported here.

`test_commit_request_states_both_preconditions` watches the two keywords
directly rather than only through their effect, and
`test_the_other_upload_requests_keep_the_defaults` pins that they stay scoped
to the commit.

7 failing, no production change yet.
tishin-endou added a commit to tishin-endou/RDM-waterbutler that referenced this pull request Sep 24, 2026
Adds the tests for 決定-13 ahead of the implementation.  117 new tests, all
failing: `S3Provider` has no `UPLOAD_MAY_HAVE_COMPLETED_MESSAGE`,
`_commit_outcome_note` or `_observed_error_code`, and the provider module has
no `DEFINITIVE_REJECTION_CODES`.

What the tests say the commit's outcome is.  Three values -- it succeeded, it
definitely did not happen, or nobody knows -- and the difference that matters
is the last two.  "The file was not saved" tells the user to upload again, and
saying it when the object is in fact on the storage costs a second copy that
only an administrator can remove.  So UNKNOWN is the fallback: an unknown code
and a missing code both land there.

The verdict comes from the S3 error code alone.  The HTTP status class cannot
carry it -- a failed CompleteMultipartUpload arrives as 200 with an <Error>
body, so the status says nothing about how definite the storage was.
`test_the_note_ignores_the_status_class` states that directly and the 96-cell
product states it through five transports.

The product is taken in one place on purpose.  Per-transport parameter sets
cannot expose a contradiction *between* transports, and that is how all three
of PR RCOSDP#98's review rounds missed one.  The 48 observable cells (three
transports x eight codes x two abort outcomes) assert that the same observed
code gives the same verdict everywhere; the 32 latent cells (disconnect,
truncated body) assert that no code is observable at all, with the premise
spied on so that an implementation emitting the notice unconditionally cannot
pass them.  The code string is planted in the disconnect's message and inside
the truncated body, so reading a code from anywhere but a parsed response body
-- or by substring -- fails.

`_complete_multipart_upload` is never replaced by a mock.  NOTE_SEMANTICS_DESIGN
v2.2 §4-2d: a test that judges the notice must not mock the code that decides
it.  Injection goes to the boundary below (`make_request`, `resp.read`) or
above (`_upload_parts`).  RCOSDP#98 measured two mutations surviving 360 tests when
that rule was broken.

Table rows get one parameter each rather than being generated from the
implementation, so that deleting a row cannot delete its own test -- 4 of 6
row-deleting mutations survived in RCOSDP#98 for that reason.

Two deviations from RCOSDP#98, to be recorded in PHASE_TK2_REPORT:

* the quota-suppression branch is not ported.  K-11 established that `s3` has
  no quota mechanism at all -- absent from `ADDON_METHOD_PROVIDER` and from
  `website/util/quota.py`'s `PROVIDERS` -- so there is no quota response to
  suppress.
* K-1's abort-outcome wording stays, and the notice is combined with it rather
  than replacing it.  They answer different questions: "is the object there?"
  and "is there rubbish left behind?".
tishin-endou added a commit to tishin-endou/RDM-waterbutler that referenced this pull request Sep 24, 2026
…4, green)

Ports PR RCOSDP#98's three-valued commit outcome.  A multi-part upload whose commit
fails now says one of two things: the file was not saved, or it may have been
saved and the file list should be checked first.  Until now it said neither --
the message named an unexpected error and left the user to guess, and guessing
wrong in the "not saved" direction means uploading again on top of an object
that is already there.

The verdict comes from the S3 error code alone.  `DEFINITIVE_REJECTION_CODES`
holds the nine codes that prove nothing was assembled; every other code, and
the case where no code could be read, is UNKNOWN and gets the notice.  The
asymmetry is deliberate: an over-reported notice costs a look at the file list,
an under-reported one costs a duplicate object that only an administrator can
remove.

The HTTP status class is not consulted, and cannot be.  S3 sends the status
line before it starts assembling the parts, so a commit that fails part way
through arrives as 200 with an <Error> body -- which K-3 already turns into a
502.  Classifying on that would report the storage's most definite answers as
server errors.

The model is sound only because the commit is sent exactly once (K-5, the
previous commit).  Under a re-send the observed code belongs to the last
attempt, and a first attempt that succeeded comes back `NoSuchUpload` -- which
is why `NoSuchUpload` is not in the table.

Marking happens at the commit boundary, not at the decision.  `retry=0` closes
the commit into a single `await`, so "sent" is just "entered that await":
everything from the request through the body read is marked, whatever the
exception type.  Narrowing that `except` would drop the notice for exactly the
failures nobody anticipated.  A connection failure after entering the await may
in fact never have reached the wire; that is not distinguishable here, so it
goes to UNKNOWN.

Two deviations from RCOSDP#98:

* RCOSDP#98 suppresses the notice for quota exhaustion ahead of the table, because
  "you are out of space" and "it may have completed" contradict each other.
  Not ported: K-11 established that `s3` has no quota mechanism at all --
  absent from `ADDON_METHOD_PROVIDER` and from `website/util/quota.py`'s
  `PROVIDERS` -- so there is no quota response here to suppress.
* K-1's abort-outcome wording stays.  RCOSDP#98 has no equivalent, so there was
  nothing to combine it with there.  Here the notice goes between the failure
  sentence and the abort outcome: "is the object there?" and "is there rubbish
  left behind?" are different questions and the user needs both answers.

The table is transcribed from MinIO measurements (NOTE_SEMANTICS_DESIGN v2.2
§2-2).  Only `EntityTooSmall` was actually observed on a CompleteMultipartUpload;
the other eight rest on the S3 specification and MinIO's error definitions.
AWS S3 is unverified -- TEST_SPEC E-1 reconciles it.

This is a GRDM difference (G-8), marked `# GRDM:` and excluded from the
upstream drafts.

Mutations: M-10 (drop `EntityTooLarge` from the table) -> 2 failed, killed by
the per-row parameter and the table pin.  M-11 (return `''` instead of the
notice) -> 79 failed.  M-7 (invert the abort branch) still kills, now 6 failed.

s3: 290 passed.  Full suite: 2169 passed / 7 skipped, flake8 clean.

@yacchin1205 yacchin1205 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.

コメントを記載させていただきました。全体的に、実施したいことに対して、過剰なコメント、不要と思われる処理などが多いと感じました。見直していただけますと幸いです。

return None, None
try:
parsed = xmltodict.parse(body)
except ExpatError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

XMLの解析失敗を (None, None) に置き換えると、「応答が壊れていて読めなかった」と「正常に読めたが対象フィールドがなかった」を呼び出し元で区別できません。ストレージ側の異常応答なのか、こちらの文字コード変換・解析処理の問題なのかを調べる際に、必要な原因情報が失われます。解析失敗は原因を保持して伝播するか、明示的な異常応答エラーに変換してください。

S3互換ストレージは、製品によって対応範囲や挙動に差異があります。そのため、予期しない形式は早期にエラー報告をした方が、互換性検証もしやすく、全体として良いように感じます。現状のコードですと、この処理が正常に動作しない、と利用者から問い合わせがあった場合、調査が非常に困難になりそうです。

なお、以下のようなコードも同様です。Noneに落とすにしても、Logging等の配慮がないと、後で「なぜ動作しないのか?」の調査が困難になることが予想されます。

    if not isinstance(parsed, dict):
        return None, None

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

ご指摘のとおり、解析失敗の原因を失っていました。失敗の種別(XMLとして解析不能 / Error が要素として存在しない / Error はあるが Code がない)を区別し、例外の型と応答本文(上限つき)をWARNINGで記録するようにしました(703f007)。戻り値を別のエラーに変換していない理由は、この関数がストレージのエラーステータス(403等)を受け取った後の分類に使われるためで、ここで変換するとストレージ本来のステータスが失われます。成功ステータスで異常本文が返る経路(_check_for_200_error)は既に明示的な502への変換になっています。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ありがとうございます。 703f007 では、

        if not isinstance(parsed, dict):
            return None, None

が、そのままになっています。理由はありますでしょうか?

これが修正漏れなのだとすると、
思ったのは、isinstanceでチェックしながら落とすのではなく、 error['Code'] のような、シンプルなPython呼び出しの形で実装して、それをtry ~ exceptで囲って、errorはloggingする形で落とした方が、見通しが良いし、このような漏れが少ないのではと感じました。ご検討ください。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

ご指摘のとおり修正漏れです。最初のコメントで引用いただいていた箇所にもかかわらず、当該分岐にログを付けておらず、申し訳ありません。ご提案の形——素直にアクセスし、失敗を例外として1箇所で受けてログする——に書き直しました(4a428d6)。isinstance による分岐と個別のログは廃止し、例外の型とメッセージで原因が分かるようにしています(補足: xmltodict は解析できた入力に対して常にマッピングを返すため当該分岐は実際には到達しないものでしたが、ご指摘の「漏れやすい構造」の問題はそのまま当てはまり、構造ごと改めました)。見通しの改善につながるご提案、ありがとうございます。同じ構造だった _check_for_200_error の取得部分も同じ形に揃えています(cd586d8)。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ありがとうございます。エラー出力について... type(err).__name__ のようにしていますが、このコードですと、Tracebackは失われるかと思います。これは意図したものでしょうか?(手間をかけて、情報を削っているように見えます。) logger.exception()等、Tracebackを出力する形でのログ出力をすべきではないと判断した理由があれば、教えてください。また、修正内容に対して、commitが非常に多数になり、マージ後に問題調査などの妨げになる恐れがあると考えます。適切なsquash等ご検討いただけますと幸いです。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

ご指摘ありがとうございます。①は意図したものでしたが、過剰でした。aiohttpの接続エラーはメッセージにpresigned URL(署名を含む)を埋め込み、またストレージ応答由来のエラーはメッセージに応答本文を持つため、logger.exception() ではそれらがトレースバックの最終行としてログに出てしまう、というのが型名のみにした理由です。ただ、スタックまで捨てる必要はありませんでした。スタックフレームは全て出力し、例外メッセージはURLのクエリ文字列を除去・長さ上限つきで出力する形に改めました(4589eaa)。②コミットは意味の単位6本に整理しました(force-push済み。整理前後でツリーが同一であることを確認しています)。

Comment thread waterbutler/providers/s3compatsigv4/provider.py Outdated
Comment thread waterbutler/providers/s3compatsigv4/provider.py Outdated
Comment thread waterbutler/providers/s3compatsigv4/provider.py Outdated
@tishin-endou

Copy link
Copy Markdown
Author

ご指摘ありがとうございます。コメントについては、レビュー過程の設計判断をコード内に残しすぎており、保守性の観点でご指摘のとおりでした。「定義箇所に理由を1回・呼び出し箇所では繰り返さない・依存ライブラリやテストへの言及は本体に書かない・コードで自明な動作は書かない」を基準に全体を見直しました(0fbfe05, d9156ce, 7cfb7ee: いずれもコメント/docstringのみでASTは同一。provider.py のコメント行 359→78)。処理については、XML解析失敗の原因を残すログの追加と正規表現の事前チェックの削除を行いました(703f007)。CI は全件通過しています。

@tishin-endou

tishin-endou commented Oct 1, 2026 •

Copy link
Copy Markdown
Author

レビュー中の追加になり恐縮ですが、1コミット(ccc5fd3)を追加しました。フォルダ一括削除で DeleteObjects が HTTP 200 の応答内に鍵ごとの失敗を返した場合、現行では読まずに破棄しており運用ログに痕跡が残らない問題への対応です「エラーの握りつぶし」として指摘を受けていた類型のため、凍結前に含めました)。ログ出力のみで利用者への応答は変更していません。あわせて boto3 の例外を型名とコードを持つエラーにラップし、素の500にならないようにしています。利用者にも失敗として見せる対応は別PRで行います。本PRから分離した方がレビューしやすければ別PRに切り出します。

tishin-endou added a commit to tishin-endou/RDM-waterbutler that referenced this pull request Oct 1, 2026
Error reporting.  One helper, _raise_from_client_error, now stands at every site
that catches a botocore or make_request failure, and reports the exception's type
name and S3's error code and nothing else.  The messages it replaces both carry
material that must not reach a client or a log: botocore quotes S3's prose with
its request and host ids, and core's exception_from_response quotes the request
URL, which under SigV4 is a presigned URL carrying X-Amz-Credential -- the access
key id -- and X-Amz-Signature.  waterbutler.server.api.v1.core.write_error hands
exc.message to the client, so check_key_existence was answering 404 with the
signature in the body, and _chunked_upload was logging the same through repr().
The two conversion points also raise `from None`: leaving the original on
__context__ meant traceback.format_exception -- reached through log_exception's
exc_info -- printed the very message they refused to copy.  The original is still
logged at both sites, by type and status.

The helper re-raises asyncio.CancelledError unchanged.  Python 3.6 derives it
from Exception, so each of these broad excepts was reporting a cancelled request
as a provider failure and stopping the cancellation from propagating.
_chunked_upload gets its own guard because it aborts the session before it
reports.  get_s3_bucket_object_location had no handler at all: a botocore error
left the provider as itself and the API layer could only answer 500 with no code,
and it is the first request of every operation, so that is the least informative
place to lose the error.

Response parsing.  xmltodict keys elements by the name as written, so a listing
whose root is spelled <s3:ListBucketResult>, or a body that is not XML, left
doc.get() giving back {} -- indistinguishable from an empty bucket.  A folder
would list as empty and a delete would report success having found no version to
remove.  _parse_listing fails closed with a 502 instead.

The commit.  S3 sends the status line before it starts assembling a multi-part
upload, so a commit that fails part way through arrives as 200 with an <Error>
body; expects=(200, 201) only looks at the status and read that as a completed
upload.  The body is now read, and a commit counts as successful only when the
body says so -- a CompleteMultipartUploadResult carrying an ETag, which is
computed from the assembled object and therefore exists only once the assembly
finished.  An empty or scalar <Error>, a body that is not XML, a truncated or
empty body, a result with no ETag and an unknown root element are all evidence
for neither outcome, so they raise rather than reporting a file that may not be
there.

The commit is now sent exactly once: retry=0 stops core's retry loop and
allow_redirects=False stops aiohttp following a 307/308 below it, and neither
substitutes for the other.  The scope is the commit alone -- the session
creation, the part uploads and the abort keep core's defaults, where a re-send
changes nothing the caller reads.  The accepted cost is that a 503 or 504 on the
commit reaches the user on the first attempt instead of the third; Complete is
not idempotent, and a second attempt after a first that actually succeeded
answers NoSuchUpload, which would be read as a definitive rejection.

A failed commit now tells the user which of two things happened: the file was not
saved, or it may have been saved and the file list should be checked first.  The
verdict comes from the S3 error code alone -- a table of the codes that prove
nothing was assembled; every other code, and the case where no code could be
read, is unknown and carries the notice.  The asymmetry is deliberate: an
over-reported notice costs a look at the file list, an under-reported one costs a
duplicate object that only an administrator can remove.  The HTTP status class is
not consulted and cannot be, since a failed commit arrives as a 200.  The table
is transcribed from MinIO measurements; only EntityTooSmall was actually observed
on a CompleteMultipartUpload, and reconciliation against AWS S3 is a separate
piece of work.

This ports RCOSDP#98's three-valued outcome, with two deviations.
RCOSDP#98 suppresses the notice for quota exhaustion, because "you are out of space"
and "it may have completed" contradict each other; s3 has no quota mechanism at
all -- it is absent from ADDON_METHOD_PROVIDER and from website/util/quota.py's
PROVIDERS -- so there is no quota response here to suppress.  And the abort
outcome keeps its own wording, with the notice placed between the failure
sentence and it: "is the object there?" and "is there rubbish left behind?" are
different questions and the user needs both answers.

Finally, an abort that itself raised used to replace the verdict entirely.
_abort_chunked_upload returns False only when it read answers and parts were
still there; a DELETE answering 404, 403 or 500 comes back as an exception,
raised after the notice is computed and before it is used, so both the failure
sentence and the notice were discarded and the user heard only about the cleanup.
404 NoSuchUpload on the abort is the worst case and a real answer -- it is what
S3 says when the UploadId is already consumed, which is exactly when the user
most needs to go and look.  The abort is wrapped, treated as not aborted, and all
three sentences are reported.  CancelledError is re-raised first.

Also fixes a test-isolation defect this work uncovered: a failing
ClientSession._request installed through monkeypatch was undone at teardown,
after the conftest hook had already deactivated aiohttpretty, which put
aiohttpretty's fake back permanently for every later test that needs a real
socket.  It is restored inside the test body instead.
_abort_chunked_upload logged "failed to remove temporary parts" on success
and stayed silent on failure, reporting part-residue status backwards.
… 507

Add _parse_s3_error_body / _raw_error_body to extract S3 XML error Code
and Message.  Map known quota codes (QuotaExceeded,
XMinioAdminBucketQuotaExceeded, XMinioStorageFull) and HTTP 507 to a
user-facing 507 with actionable message.  Raw XML and presigned URLs are
never exposed to the caller.
… add commit-outcome model

- Connection errors and unreadable responses become HTTP 502.
- resp.release() in raise paths prevents connection pool leaks.
- QUOTA_EXCEEDED_ERROR_CODES from provider config; malformed JSON falls
  back to defaults with a warning instead of breaking the provider.
- CompleteMultipartUpload can return 200+<Error>.  _commit_outcome_note
  classifies commit results as NOT_COMMITTED / UNKNOWN using an
  error-code table.  retry=0 and allow_redirects=False guarantee
  exactly one POST on the socket.
- _parse_s3_error_body: remove regex pre-filter, rely on XML parse.
- Comments and docstrings trimmed to intent-only.
…tocore errors

When DeleteObjects returns HTTP 200 with per-key errors inside the body,
the failures were silently discarded.  Now logged as WARNING with the
failed key count and first five keys.  botocore exceptions are wrapped
with type and code instead of surfacing as a bare 500.
…vering set

Mechanically integrate and deduplicate PR-added tests using a 74-variant
mutation matrix.  Test functions reduced from 104 to 101 (PR-added: 73
to 31); test cases from 375 to 152.  All 70 develop-branch test
functions unchanged.  Mutation detection: 71 killed / 3 documented
equivalent / 0 mismatches — identical before and after.
@tishin-endou
tishin-endou force-pushed the fix/s3compatsigv4-quota-handling branch 2 times, most recently from 6e82db4 to 03fd831 Compare October 2, 2026 03:26
- _parse_s3_error_body and _check_for_200_error rewritten from isinstance
  chains to try/except (EAFP).  _local_name_lookup raises KeyError when
  key absent and no default given.
- Add _log_exception: outputs stack frames via traceback.format_tb
  (no exc_info — the formatter would append unredacted str(exc) containing
  presigned URLs).  Exception message is redacted separately.  Replaces
  all type(err).__name__-only log sites.
@tishin-endou
tishin-endou force-pushed the fix/s3compatsigv4-quota-handling branch from 03fd831 to 4589eaa Compare October 2, 2026 05:23
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