0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiita の「403 Forbidden」の真犯人は、タグの半角スペースだった

0
Last updated at Posted at 2026-07-17

🔗 この記事は https://blog.tak3.jp/ja/blog/qiita-403-tags/ からの転載です(一次情報源)。

以前、このブログの構成を書いた記事で、外部配信について正直に打ち明けた。「Qiita への自動投稿は 403(Forbidden)で止まっている。トークンのスコープ不足でもレート制限でもなく、アカウント単位で API 経由の記事作成が拒否されている状態だ」と。

結論から言う。これは間違いだった。 アカウントは制限などされていなかった。真犯人はもっと地味で、もっと自分のミスに近かった — タグ名に入れた半角スペースだ。本稿はその切り分けの記録である。

症状 — 3つの食い違い

配信サブシステム(Cron で回る別 Worker)は、記事の syndication.qiitaenabledrepublish のとき Qiita へ POST/PATCH する。ある日、3つのことが噛み合わなくなった。

  1. 新規投稿できた記事と、できない記事があった。 ある記事は POST が通り、別の記事は 403 で弾かれる。
  2. republish にした記事を編集しても、Qiita 側に反映されない。 更新の PATCH が毎回 403 で落ちていた。
  3. エラーは一貫して {"message":"Forbidden","type":"forbidden"} 403 で、しかも「forbidden」。誰がどう読んでも権限の問題に見える。

3 のせいで、最初に疑ったのはアカウントかトークンだった。前回の記事に「アカウント制限」と書いたのはこのためだ。だがこの見立ては、記録を読み直したら崩れた。

配信履歴を読む — 「成功」なのにエラーが残っている

この配信システムは、投稿の結果を D1(SQLite)の履歴テーブルに残している。冪等キーは (target, article_id)。当該記事の行を引くと、こうなっていた(一部マスク)。

target      = qiita
article_id  = introducing-basou:ja
status      = success
external_id = 2219…ea02        ← 初回 POST は成功して記事は存在する
source_sha  = 93b4…0e24        ← 最新の本文 sha まで前進している
attempt     = 2
error       = qiita 403 Forbidden — {"message":"Forbidden","type":"forbidden"}

statussuccess なのに、error に 403 が残り、attempt は 2。この一見矛盾した状態が、実は正しく設計どおりの痕跡だった。

配信 Worker は、更新(PATCH)が 非 retryable な 4xx(401/403/404/422 など)で失敗したとき、こう振る舞う。

  • ステータスを success に戻す(初回投稿自体は生きているので)
  • source_sha を最新へ前進させる(=「この本文についてはもう諦めた」の印)
  • エラー本文を記録する
  • 同じ本文では二度と再試行しない

429 やネットワークエラーのような一時的な失敗は sha を据え置いて次の Cron で再試行するが、4xx は「直らない」と判断して諦める設計だ。だから履歴には「初回は成功・その後の更新は 403 で恒久失敗・本文 sha は前進済み」という、まさにこの通りの姿が残っていた。Worker はバグっていなかった。 Qiita が 403 を返し、それを受けて設計どおり諦めていただけだ。

「Forbidden」に騙された

ここで見落としていた事実がひとつある。同じトークンで、POST は成功していた記事があった。 初回投稿は通り、その後の更新 PATCH だけが 403 になる。もしトークンのスコープ不足やアカウント凍結なら、POST も PATCH も等しく落ちるはずだ。片方だけ通るのは、静的な権限の問題では説明がつかない。

403 の type: "forbidden" は、いかにも権限を指しているように読める。だが実際には、Qiita はリクエストの内容が制約に反したときにも 403 を返していた。エラーメッセージは、原因の在り処について嘘をついていたわけだ。

真犯人 — Qiita はタグ名の空白を区切りとして扱う

手動で Qiita のダッシュボードから同じ記事を更新しようとして、ようやく気づいた。タグの保存で弾かれる。

Qiita のタグには2つの制約がある。

  • タグは最大5個。
  • タグ名の半角(および全角)スペースは、タグの区切りとして扱われる。

つまり Claude Code という1つのタグは、Qiita の中では ClaudeCode2つに分裂する。ここに、さきほど履歴に出てきた記事(introducing-basou:ja)のタグを当てはめてみる。

frontmatter のタグ Qiita 換算
AIエージェント 1
AIコーディング 1
ハーネスエンジニアリング 1
Claude Code 2(Claude / Code
Codex 1
合計 6 → 上限 5 超過で拒否

配信 Worker はタグを「最大5個」に切り詰めてはいた。ただし数えていたのはタグ文字列の個数であって、Qiita がスペースで分割したあとの個数ではなかった。Claude Code を1個と数えて5個に収めても、Qiita 側では6個に膨らんで上限を超える。これが 403 の正体だった。

最高に皮肉だったのは、前回の「配信パイプラインを解説した記事」自身が、まさにこれで自動投稿に失敗していたことだ。あの記事のタグはこうだった。

frontmatter のタグ Qiita 換算
Astro 1
Sveltia CMS 2
Cloudflare Workers 2
i18n 1
合計 6 → 拒否

4つのつもりが、Qiita 換算では6つ。配信の自動化を説明する記事が、その配信先のタグ規則に足を掬われて手動投稿になっていた。うまく自動投稿できていた記事は、たまたまタグが少なく、分割しても5個以内に収まっていただけだった。

直し方 — 分割ではなく、畳む

修正は正規化関数の1行だった。従来は前後の空白を落とす trim() だけ。これを、タグ名の内部空白まで除去するように変えた。

// 修正前: 前後の空白しか落とさない → "Claude Code" がそのまま渡り Qiita で2分裂
const name = t.trim();

// 修正後: 内部空白も含めて全除去 → "Claude Code" → "ClaudeCode"(全角空白も \s で対象)
const name = t.replace(/\s+/g, '');

とりうる手は他にもあった。空白で分割して ClaudeCode の2タグにする案だ。だがこれは CodeCMSWorkers といった、単体では意味をなさないタグを量産する。そこで畳む方を選んだ — Claude CodeClaudeCodeSveltia CMSSveltiaCMS。1つの frontmatter タグが、必ず1つの Qiita タグに対応する。こうすれば上限のカウントも畳んだ後に効くので、Qiita 換算でも確実に5個以内に収まる。手作業で直したときに自分が選んだ表現(ClaudeCode)とも一致した。

ブログ自身のタグページは Claude Code のような空白入りのタグを問題なく扱える。配信先の Qiita は扱えない。同じ「タグ」でも、表示する場所ごとに正規化のルールが違う。 配信レイヤーは、その差を吸収する責務を持つべきだった。

学んだこと

  • エラーメッセージは原因の在り処について嘘をつく。 403 Forbidden type: forbidden は権限を指しているように見えて、実際は入力内容の制約違反だった。ステータスコードと文言だけで原因を決めつけると、この記事の前半のように数日回り道する。
  • 手元に残る記録が、切り分けを可能にした。 「成功なのに 403、attempt=2、sha は前進済み」という D1 の一行が、「Worker は諦めただけで壊れてはいない」「Qiita 側が拒否している」という切り分けを与えてくれた。ログとステータスがベンダーの管理画面の奥ではなく手元にあること自体が、デバッグの前提だった。
  • 「直らない失敗」の扱いは諸刃だ。 403 を恒久失敗として諦める設計は、4xx の無限リトライを防ぐ一方で、原因が直っても本文が変わるまで再試行しない。恒久失敗として諦めたレコードは、直したあとに手で起こし直す必要がある。
  • 外部プラットフォームの制約は、ドキュメントより実測で分かることがある。 タグの空白分割は、手動保存が弾かれて初めて腑に落ちた。

配信の自動化は、うまく動いているときほど何も語らない。止まったときに初めて、その内側の設計と、外の世界の理不尽さの両方が見える。今回はその両方を、正直に書いておく。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?