エラーカタログ & 構造化ログ Error Catalog & Structured Logging

Parky BFF(Cloudflare Workers)が返すすべての HTTP エラーコードと、 観測性のために吐く 1 行 JSON ログの仕様をまとめます。 実体は api/src/lib/errors.tsERROR_CATALOG / ApiError / ショートハンド) と api/src/lib/logger.ts、 正規化と response 整形は api/src/middleware/error-handler.ts

The single source of truth for every HTTP error code returned by the Parky BFF (Cloudflare Workers) and for the one-line JSON log format. Code lives in api/src/lib/errors.ts (ERROR_CATALOG / ApiError / shortcuts), api/src/lib/logger.ts, and the normalization + response shaping happens in api/src/middleware/error-handler.ts.

SSoT: SSoT: 新しいコードを増やす時は api/src/lib/errors.tsERROR_CATALOG にだけ追加する。このドキュメントは catalog に揃える。 When adding a new code, touch only ERROR_CATALOG in api/src/lib/errors.ts. This document tracks the catalog.

1. 応答フォーマット1. Response format

すべてのエラー応答は統一形式。ステータスコードは code から決まる(ルート側で status を指定する必要はない)。

Every error response uses the same shape. Status is derived from code — route handlers never pass status explicitly.

// 実装: middleware/error-handler.ts (B-01 Stripe-tier envelope)
{
  "error": {
    "code":        "not_found",                      // ErrorCode(下表参照)
    "message":     "parking_lot not found",          // 人間可読メッセージ
    "message_key": "errors.not_found",               // i18n key(lib/i18n-keys.ts → ApiError.messageKey で override 可)
    "request_id":  "018f3b7a-4b5c-4c4b-...",         // x-request-id ヘッダと一致
    "param":       null,                             // ZodError なら issues[0].path[0]、それ以外 null
    "doc_url":     null                              // ERROR_CATALOG[code].doc_url(一部 code のみ)
  }
}

message_key / param / doc_url は B-01 (2026-04-28) で追加。 旧クライアントとの互換のため optional + nullable で常時付与。doc_url 形式は https://docs.parky.co.jp/errors/<code>

message_key / param / doc_url were added in B-01 (2026-04-28). They are always emitted (optional + nullable) for backward compatibility. doc_url follows https://docs.parky.co.jp/errors/<code>.

1.1 throw → response の流れ1.1 throw → response flow

flowchart LR
  R[route handler
throw badRequest / ApiError / HTTPException] --> EH[middleware/
error-handler.ts] EH --> N[normalizeToApiError] N -->|HTTPException| F[fromHTTPException] N -->|Error その他| INT[ApiError 'internal_error' 500] F --> AE[ApiError] N -->|ApiError| AE AE --> RP[buildApiErrorResponse
code / status / message_key / param / doc_url / request_id] AE -->|status >= 500| SEN[Sentry capture] AE -->|prod| RED[redactErrorForProduction
stack/cause/detail を落とす] RED --> LOG[logger warn 4xx / error 5xx] RP --> CLIENT[Client]

クライアントは code で機械的に分岐し、message は UI に表示しない(i18n 対応のため、 ユーザー向け文言はクライアント側で code に対応するローカライズ文字列を用意する方針)。 request_id はサーバーログと突合するキー。

Clients branch on code programmatically and do not render message as-is (for i18n, map each code to a localized string on the client side). request_id correlates with server logs.

2. エラーコード一覧(ERROR_CATALOG2. Error codes (ERROR_CATALOG)

2-1. 汎用 HTTP コード2-1. Generic HTTP codes

code status 用途 Purpose 代表的な投げ場所 Typical call site
bad_request400 入力バリデーション / 業務エラーの総合枠 Input validation and miscellaneous client errors throw badRequest("...") throw badRequest("...")
unauthorized401 Bearer トークン欠如 / 検証失敗 Missing or invalid bearer token middleware/auth.ts middleware/auth.ts
forbidden403 認証済みだが権限不足(非 admin / 停止中 admin 等) Authenticated but lacking permission (non-admin, suspended admin, etc.) requireAdmin, RBAC requireAdmin, RBAC
not_found404 リソース非存在 / Postgrest PGRST116 Missing resource / Postgrest PGRST116 throw notFound("...") throw notFound("...")
conflict409 一意制約違反(Postgres 23505 Unique-constraint violation (Postgres 23505) translatePgError translatePgError
unprocessable_entity422 Zod バリデーション失敗(OpenAPIHono defaultHook Zod validation failure (OpenAPIHono defaultHook) src/index.ts の defaultHook defaultHook in src/index.ts
rate_limited429 Cloudflare Rate Limiter / ユーザー単位抑制 Cloudflare Rate Limiter on /v1/search/ai etc. RATE_LIMIT_USER binding RATE_LIMIT_USER binding
internal_error500 想定外の例外 / PostgresError で特定 code 無し Unexpected exception / generic PostgresError error-handler のフォールバック error-handler fallback
bad_gateway502 上流 API 失敗(LLM / Workers AI / FCM 等) Upstream API failure (LLM, Workers AI, FCM, etc.) throw badGateway("...") throw badGateway("...")
service_unavailable503 binding 未設定 / 一時的不稼動 Binding not configured / temporary outage throw serviceUnavailable("...") throw serviceUnavailable("...")
method_not_allowed405 OpenAPI method 違反 / CORS preflight 後の許可外 method OpenAPI method violation / disallowed method after CORS preflight throw methodNotAllowed("...") throw methodNotAllowed("...")
timeout504 外部 API / DB の上流タイムアウト(retryable: true Upstream timeout from external API / DB (retryable: true) new ApiError("timeout", "...") new ApiError("timeout", "...")

各 entry は retryable / clientActionreauthenticate / retry_after / upgrade_plan / contact_support) / doc_url を任意で持つ(M-24 拡張、E-4 doc URL)。SDK はこれを参照して自動再送 / 再認証 / 課金導線を選択する。

Each entry can carry optional retryable / clientAction (reauthenticate / retry_after / upgrade_plan / contact_support) / doc_url (M-24 + E-4). SDKs use these to drive auto-retry, re-auth, or upgrade flows.

2-2. 業務固有コード2-2. Business-specific codes

汎用コードに寄せきれない「UI がコードで分岐したい業務エラー」はカタログに個別追加する。 命名は英語小文字スネークケース、status は最も近い HTTP 区分に合わせる。

Business errors that clients must branch on by code (beyond the generic codes) are added individually. Names are lowercase snake_case and status follows the nearest HTTP category.

code status 意味 Meaning 発生箇所 / PG ERRCODE Origin / PG ERRCODE クライアントの期待動作 Expected client behavior
iap_not_configured503 IAP 用の Apple / Google シークレットが BFF に未投入 IAP secrets (Apple / Google) not yet provisioned on the BFF lib/iap/index.ts lib/iap/index.ts 「現在課金処理を受け付けていません」相当のガイダンスを表示 Show a "billing temporarily unavailable" message
unknown_product422 クライアントから来た productIdsubscription_plans に無い productId from client has no matching row in subscription_plans lib/iap/index.ts lib/iap/index.ts アプリのプラン表を再取得(マスター不整合) Refresh the plan master on the client (config drift)
app_user_not_found404 JWT の auth_user_id に対応する app_users 行なし No app_users row matches the authenticated auth_user_id P0001 P0001 onboarding 画面へ。プロフィール自動生成フローを再実行 Route to onboarding; re-run the profile bootstrap flow
parking_lot_not_found404 指定された parking_lot_id が存在しない / 削除済み parking_lot_id does not exist or was soft-deleted P0002 P0002 キャッシュされた id を破棄して再検索 Drop the cached id and retry search
parking_session_not_found404 セッション id 未存在 / 他人のセッション Session id does not exist or belongs to another user P0004 P0004 履歴画面に戻る Navigate back to history
concurrent_session_in_progress409 既に status='parking' のセッションが存在する An active status='parking' session already exists P0003 P0003 進行中セッションを表示し、finalize / cancel を促す Surface the in-progress session and prompt to finalize / cancel
invalid_session_state409 状態遷移が不正(例: completed を finalize しようとした) Illegal state transition (e.g. finalize on completed) P0006 P0006 セッション詳細を再取得して UI を同期 Refetch session detail to reconcile UI
cancel_window_expired410 開始から 5 分を超過し、cancel 不可 More than 5 min since start; cancel no longer allowed P0007 P0007 「5 分を過ぎたためキャンセルできません」→ finalize を案内 "Cancel window expired" — direct user to finalize instead
idempotency_conflict409 同じ client_request_id で異なるペイロードを送った等、冪等性違反 Same client_request_id reused with a different payload ルート側で検出 Detected at route layer クライアントのキー生成ロジックを要修正 Fix the client's key-generation logic
subscription_required402 有料機能へのアクセスを無料プランで試みた Free-plan user tried to access a paid feature ルート側で plan チェック(今後の拡張点) Plan check in route handlers (future hook) 課金モーダル / プラン比較画面へ誘導 Route to the pricing / upgrade screen
plan_limit_exceeded409 プランの上限(車両登録数 / 保存駐車場数 等)に到達 Hit a plan-specific cap (vehicles, saved lots, etc.) ルート側で count & check Count + check in route handlers 「上限に達しました」メッセージ + アップグレード誘導 Show "limit reached" UI with an upgrade CTA
owner_not_found403 JWT の auth user が owners テーブルに紐付いていない The authenticated user has no matching row in owners middleware requireOwner middleware requireOwner オーナー申請画面へ誘導 Route to the owner-application flow
owner_status_inactive403 owners.status が active ではない(reviewing / suspended) owners.status is not active (reviewing / suspended) middleware requireOwner middleware requireOwner 審査中 / 停止のステータス画面を表示 Show the "under review" / "suspended" state screen
owner_not_lot_owner403 自分が所有していない駐車場を触ろうとした Tried to act on a parking lot the current owner does not own /v1/owner/* のルート側チェック Route-level check in /v1/owner/* 権限エラーとしてメッセージを表示 Display as a permission error
owner_application_duplicate400 同じ駐車場に「審査中」の申請が既にある An in-review application already exists for this parking lot /v1/owner/authority-requests /v1/owner/authority-requests 既存申請の詳細画面へ誘導 Surface the existing application's detail
owner_review_not_for_your_lot403 自分の駐車場以外のレビューに返信しようとした Tried to reply to a review on a parking lot you do not own /v1/owner/reviews/{reviewId}/reply /v1/owner/reviews/{reviewId}/reply 権限エラーとしてメッセージ Display as a permission error
owner_boost_not_found404 ブーストが存在しない or 自分のブーストではない Boost does not exist or is not yours /v1/owner/boosts/* /v1/owner/boosts/* 一覧画面に戻す Return to the boost list
auth_gate_required401 Anonymous で Free 必須機能を叩いた Anonymous user hit a Free-only feature lib/auth-gate.ts lib/auth-gate.ts AuthGateModal 表示 → 登録後に元操作 retry Show AuthGateModal, retry the original action after sign-up
premium_required402 Free 会員が Premium 必須機能を叩いた Free-tier user hit a Premium-only feature route plan check Route plan check Premium 訴求モーダル / 課金導線 Show Premium upsell modal / pricing CTA
trial_already_used / variant_not_assigned409 トライアル既使用 / プラン variant 未割当 Trial already consumed / pricing variant not assigned core/membership core/membership プラン画面でステータスを表示 Surface the status on the pricing screen
owner_password_token_invalid / token_expired / already_used / invalidated 410 パスワード設定リンクが無効 / 期限切れ / 使用済 / 無効化 Password setup link is invalid / expired / already used / invalidated /v1/owner-public/password-setup /v1/owner-public/password-setup 「リンク再発行を依頼」を案内 Prompt to request a fresh link
token_already_consumed410 single-use 消費済トークンの再投入(漏洩検知) Single-use token replayed (leak detection) DB レイヤで弾く Blocked at DB layer サポート連絡を案内(contact_support Direct user to support (contact_support)
owner_password_update_failed500 /complete 時の Supabase Auth password 更新失敗(token は未消費のまま) Supabase Auth password update failed at /complete (token left unconsumed) /v1/owner-public/password-setup/complete /v1/owner-public/password-setup/complete リトライを促す Prompt the user to retry
otp_expired / otp_invalid422 OTP の期限切れ / 値不一致 OTP expired / value mismatch auth route Auth route 再送 OTP を促す Prompt to resend OTP
email_already_exists / email_registered_via_oauth409 同 email 重複 / 別 OAuth プロバイダで登録済 Duplicate email / already registered via different OAuth provider auth signUp Auth signUp 該当プロバイダでのログイン誘導 Route to the correct provider's sign-in flow
account_blocked403 運営側ブロック email/account の再ログイン拒否 Operator-blocked email/account refused at sign-in auth route Auth route サポート連絡を案内 Direct user to support
review_already_exists / prohibited_words_detected 409 / 422 同 lot に既存レビュー / 禁止ワード検出 Existing review on the same lot / prohibited word detected /v1/me/reviews /v1/me/reviews 既存レビュー編集 or 文言修正を促す Prompt to edit existing review or revise wording
iap_receipt_invalid422 IAP レシートが無効 / 改ざん / 期限切れ IAP receipt is invalid / tampered / expired /v1/webhooks/apple-iap / google-play /v1/webhooks/apple-iap / google-play サポート連絡 + 復旧導線 Direct to support + recovery flow
coupon_not_found / coupon_expired / coupon_usage_limit_exceeded 404 / 422 / 409 クーポンの未存在 / 期限切れ / 上限到達 Coupon missing / expired / over usage cap premium actions Premium actions 該当エラーを UI に表示 Surface as a coupon error
preset_limit_exceeded / vehicle_limit_exceeded409 プリセット 10 件 / 車両 10 台の上限到達 Hit the 10-preset / 10-vehicle plan cap core/saved / core/vehicles core/saved / core/vehicles アップグレード CTA を表示 Show upgrade CTA
lot_registration_duplicate / subscription_not_found 409 / 404 同 user + 駐車場名 + 住所の重複申請 / アクティブ subscription 不在 Duplicate lot registration request / no active subscription premium actions Premium actions 既存申請 / プラン状態を表示 Surface the existing request / plan state
invalid_delete_confirmation / account_already_deleted 422 / 409 delete-account の confirmation 不一致 / 二重削除 delete-account confirmation mismatch / duplicate delete /v1/mobile/actions/profile/delete-account /v1/mobile/actions/profile/delete-account 確認文言入力をやり直し Re-prompt for the confirmation phrase
invalid_planned_end_at / invalid_started_at / invalid_user_entered_fee / invalid_memo 422 セッション開始/finalize/メモ更新の値域違反 Range / format violations on session create / finalize / memo update core/parking-sessions core/parking-sessions 入力フォームに inline error 表示 Surface as inline form errors
lot_not_navigable409 座標を持たない駐車場のナビ起動要求 Navigation requested on a lot without coordinates /v1/mobile/actions/lots/{id}/navigate /v1/mobile/actions/lots/{id}/navigate 「住所のみのため起動不可」を表示 Show "address-only, navigation unavailable"
session_notification_not_found / notification_already_fired / notification_cancelled / notification_update_failed / planned_end_at_required 404 / 409 セッション通知の状態競合 / 前提欠落 Session-notification state conflicts / missing prerequisites core/session-notifications core/session-notifications 通知一覧を再取得して UI を同期 Refetch notifications to reconcile UI
invalid_threshold_amount / invalid_threshold_at / invalid_offset_minutes 422 price_reached / time_reached / planned_end_minus の値域違反 Value-range violations on price_reached / time_reached / planned_end_minus core/session-notifications core/session-notifications 入力フォームに inline error 表示 Surface as inline form errors
fcm_not_configured / queue_not_configured503 FCM / Queue binding 未投入(環境構築不全) FCM / Queue binding not provisioned (env misconfig) 各種 send / dispatch 経路 Send / dispatch paths サポート連絡を案内 Direct user to support
asset_not_found / vehicle_not_found404 avatar asset / vehicle が未存在 or 自分のものではない Avatar asset / vehicle missing or not owned by caller profile actions Profile actions 一覧を再取得して破棄 Refetch the list and drop the stale id
owner_required401 withOwnerContext() 未通過 handler で c.var.ownerId 未解決 c.var.ownerId unresolved when withOwnerContext() wasn't applied middleware/owner-context.ts middleware/owner-context.ts 再認証フローへ Route to re-authentication
owner_boost_create_failed500 ブースト作成 SQL の失敗(DB 例外を 500 で転嫁) Boost-create SQL failure (DB exception surfaced as 500) /v1/owner/boosts /v1/owner/boosts retryable=true なので自動再送可 retryable=true so SDK can auto-retry
PG RAISE と業務コードの対応: PG RAISE → business code mapping: PL/pgSQL が RAISE EXCEPTION '...' USING ERRCODE = 'P00XX' で投げるシグナルは、 lib/db.tstranslatePgError がこの表の P00XX 欄に従って 自動的に ApiError へ写像する。新しい P00XX を定義したら必ずこの表と translatePgError を 両方更新すること。 Signals raised from PL/pgSQL via RAISE EXCEPTION '...' USING ERRCODE = 'P00XX' are mapped to ApiError automatically by translatePgError in lib/db.ts. When adding a new P00XX, update both this table and translatePgError.

3. コード投げ方(コード側の書き方)3. How to throw from code

ルート / ミドルウェア / ライブラリから投げる時は、ショートハンド関数ApiError 直接インスタンス化の二通り。生の HTTPException は使わない。

Throw either via a shortcut function or by constructing ApiError directly. Never use raw HTTPException.

import {
  badRequest, unauthorized, forbidden,
  notFound, conflict, unprocessable,
  rateLimited, badGateway, serviceUnavailable,
  ApiError,
} from "../lib/errors";

// 1) ショートハンド(推奨)
if (!body.name) throw badRequest("name is required");
if (!token)     throw unauthorized("Missing bearer token");
if (row == null) throw notFound();

// 2) 業務コードは ApiError を直接
if (!creds) throw new ApiError("iap_not_configured");
if (!plan)  throw new ApiError("unknown_product", `productId=${id}`);

// 3) 原因例外は第 2/3 引数の cause に積む(ログの err.cause に出る)
try {
  await upstream();
} catch (err) {
  throw badGateway("LLM call failed", err);
}
禁止事項: Never:
  • throw new HTTPException(...) を直接書かない。全てショートハンド/ApiError 経由で統一する
  • Don't write throw new HTTPException(...) directly — always go through a shortcut or ApiError.
  • ルートハンドラ内で c.json({ error: {...} }, 4xx) を手組みしない。throw に統一
  • Don't hand-assemble c.json({ error: {...} }, 4xx) in routes — always throw instead.
  • エラーメッセージに UUID / メールアドレス / トークン等の個人情報を入れない
  • Don't put UUIDs / emails / tokens in error messages.

4. 構造化ログ(lib/logger.ts4. Structured logging (lib/logger.ts)

Workers の stdout は Cloudflare Logs / Tail / Logpush にそのまま流れるので、 すべてのログは 1 行 JSONで吐く。console.* の直接呼出しは禁止 (lib/logger.ts 内部のみ許可)。

Workers stdout flows to Cloudflare Logs / Tail / Logpush verbatim, so every log line is one-line JSON. Direct console.* calls are disallowed (allowed only inside lib/logger.ts).

4-1. 出力スキーマ4-1. Output schema

フィールド Field 型 / 例 Type / example 備考 Notes
tsts ISO8601 / 2026-04-19T12:34:56.789Z ISO8601 / 2026-04-19T12:34:56.789Z UTC。常に含まれる UTC; always present
levellevel "debug" | "info" | "warn" | "error" "debug" | "info" | "warn" | "error" LOG_LEVEL でしきい値フィルタ(dev=debug / prod=info) Gated by LOG_LEVEL (dev=debug / prod=info)
msgmsg string / "request completed" string / "request completed" 人間可読。英語推奨(日本語も可) Human-readable; English preferred
scopescope "http" | "cron.warmer" | "queue.store-sync" | "lib.llm" | ... "http" | "cron.warmer" | "queue.store-sync" | "lib.llm" | ... 発火元を識別。{category}.{name} 形式で統一 Origin identifier. Use {category}.{name} consistently
request_idrequest_id UUID UUID HTTP 経由は必ず付与。x-request-id と一致 Always set for HTTP; matches x-request-id
errerr { name, message, code?, status?, stack?, cause? } { name, message, code?, status?, stack?, cause? } warn/error 時のみ。ApiErrorcode/status 含む Emitted on warn/error; ApiError contributes code/status
任意のフィールド Arbitrary fields status, latency_ms, sync_run_id, user_id, provider, ... status, latency_ms, sync_run_id, user_id, provider, ... 検索しやすいように snake_case 推奨。自由記述 snake_case recommended for filtering; free-form

4-2. 出力例4-2. Sample lines

// HTTP アクセスログ(毎リクエスト末尾に 1 行)
{"ts":"2026-04-19T12:34:56.789Z","level":"info","msg":"request completed",
 "scope":"http","request_id":"018f...","method":"GET","path":"/v1/parking-lots",
 "status":200,"latency_ms":42}

// 4xx はアクセスログ前に warn が 1 行
{"ts":"...","level":"warn","msg":"request failed","scope":"http",
 "request_id":"018f...","method":"POST","path":"/v1/me/vehicles",
 "status":422,"error_code":"unprocessable_entity",
 "err":{"name":"ApiError","message":"Validation failed","code":"unprocessable_entity","status":422}}

// Cron
{"ts":"...","level":"info","msg":"done","scope":"cron.sponsor-proximity",
 "sponsors_evaluated":12,"users_matched":48,"notifications_sent":45,"failed":3,
 "elapsed_ms":1523}

// Queue consumer
{"ts":"...","level":"info","msg":"run finished","scope":"queue.store-sync",
 "sync_run_id":"018f...","store":"app_store","task":"reviews",
 "status":"success","rows_upserted":37}

4-3. ログの書き方4-3. How to log

// 1) HTTP ルート(c.var.log が request_id 付き logger)
app.get("/v1/foo", (c) => {
  c.var.log.info("foo fetched", { foo_id });
  c.var.log.warn("fallback triggered", { reason });
  c.var.log.error("db query failed", err, { query_id });
});

// 2) cron / queue / library
import { createLogger } from "../lib/logger";

const log = createLogger(env, { scope: "cron.my-job", run_id });
log.info("started");
log.error("task failed", err);

// 3) child logger で context を追加
const childLog = log.child({ sync_run_id });
childLog.info("sub-task done");

5. 分析クエリ例5. Analysis queries

Cloudflare Tail / Logs Engine / Logpush 先(S3/Loki 等)で jq を叩く想定。

Assuming jq against Cloudflare Tail / Logs Engine / Logpush sinks (S3, Loki, etc).

# 5xx のみ抽出
jq 'select(.level=="error")'

# 特定 request_id でトレース(HTTP → LLM → DB)
jq 'select(.request_id=="018f3b7a-...")'

# Queue 系だけを時系列で
jq 'select(.scope | startswith("queue."))'

# 特定の業務コードを集計
jq 'select(.err.code=="iap_not_configured")' | jq -s 'length'

# p95 レイテンシ(アクセスログ)
jq -r 'select(.msg=="request completed") | .latency_ms' \
  | sort -n | awk 'NR==int(NR*0.95){print}'

# scope 別のエラー件数
jq -r 'select(.level=="error") | .scope' | sort | uniq -c | sort -rn

6. 新しいコードを追加する手順6. Adding a new code

  1. api/src/lib/errors.tsERROR_CATALOG{ status, message } を追加する。命名は英語小文字スネークケース。
  2. Add { status, message } to ERROR_CATALOG in api/src/lib/errors.ts. Name in lowercase snake_case.
  3. 頻繁に使うならショートハンド関数(badRequest 等と同じパターン)を追加する。
  4. Add a shortcut function if it'll be used frequently (follow the badRequest pattern).
  5. 呼出側で throw new ApiError("new_code", "...") or ショートハンドを使う。
  6. Throw via throw new ApiError("new_code", "...") or the shortcut.
  7. このドキュメント(docs/api-errors.html)の該当テーブルに行を追加する。
  8. Append a row to the table in this document (docs/api-errors.html).
  9. クライアント側(モバイル / admin / public web)でハンドリング方針を決めて実装する。
  10. Decide and implement the client-side handling (mobile / admin / public web).