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

Parky BFF(Cloudflare Workers)が返すすべての HTTP エラーコードと、 観測性のために吐く 1 行 JSON ログの仕様をまとめます。 実体は api/src/lib/errors.tsapi/src/lib/logger.ts が SSoT。

The single source of truth for every HTTP error code returned by the Parky BFF (Cloudflare Workers), and for the one-line JSON structured log format used for observability. Code lives in api/src/lib/errors.ts and api/src/lib/logger.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.

{
  "error": {
    "code":       "not_found",                       // ErrorCode(下表参照)
    "message":    "parking_lot not found",           // 人間可読メッセージ
    "request_id": "018f3b7a-4b5c-4c4b-..."           // x-request-id ヘッダと一致
  }
}

クライアントは 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("...")

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/applications /v1/owner/applications 既存申請の詳細画面へ誘導 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/.../reply /v1/owner/reviews/.../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
owner_invite_no_link403 招待受諾時に owners.user_id に紐付きが無い No owner row is linked to the authenticating auth user on invite accept /v1/owner/invitations/accept /v1/owner/invitations/accept 招待メール再送を促す Prompt the user to request a new invite email
owner_invite_password_failed400 招待受諾時のパスワード設定が Supabase Auth で失敗 Password set failed in Supabase Auth during invite accept /v1/owner/invitations/accept /v1/owner/invitations/accept 画面でリトライを促す Prompt the user to 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).