エラーカタログ & 構造化ログ Error Catalog & Structured Logging
Parky BFF(Cloudflare Workers)が返すすべての HTTP エラーコードと、
観測性のために吐く 1 行 JSON ログの仕様をまとめます。
実体は api/src/lib/errors.ts と api/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.
api/src/lib/errors.ts の
ERROR_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_CATALOG)2. Error codes (ERROR_CATALOG)
2-1. 汎用 HTTP コード2-1. Generic HTTP codes
code |
status | 用途 | Purpose | 代表的な投げ場所 | Typical call site |
|---|---|---|---|---|---|
bad_request | 400 | 入力バリデーション / 業務エラーの総合枠 | Input validation and miscellaneous client errors | throw badRequest("...") |
throw badRequest("...") |
unauthorized | 401 | Bearer トークン欠如 / 検証失敗 | Missing or invalid bearer token | middleware/auth.ts |
middleware/auth.ts |
forbidden | 403 | 認証済みだが権限不足(非 admin / 停止中 admin 等) | Authenticated but lacking permission (non-admin, suspended admin, etc.) | requireAdmin, RBAC |
requireAdmin, RBAC |
not_found | 404 | リソース非存在 / Postgrest PGRST116 |
Missing resource / Postgrest PGRST116 |
throw notFound("...") |
throw notFound("...") |
conflict | 409 | 一意制約違反(Postgres 23505) |
Unique-constraint violation (Postgres 23505) |
translatePgError |
translatePgError |
unprocessable_entity | 422 | Zod バリデーション失敗(OpenAPIHono defaultHook) |
Zod validation failure (OpenAPIHono defaultHook) |
src/index.ts の defaultHook |
defaultHook in src/index.ts |
rate_limited | 429 | Cloudflare Rate Limiter / ユーザー単位抑制 | Cloudflare Rate Limiter on /v1/search/ai etc. |
RATE_LIMIT_USER binding |
RATE_LIMIT_USER binding |
internal_error | 500 | 想定外の例外 / PostgresError で特定 code 無し | Unexpected exception / generic PostgresError | error-handler のフォールバック | error-handler fallback |
bad_gateway | 502 | 上流 API 失敗(LLM / Workers AI / FCM 等) | Upstream API failure (LLM, Workers AI, FCM, etc.) | throw badGateway("...") |
throw badGateway("...") |
service_unavailable | 503 | 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_configured | 503 | 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_product | 422 | クライアントから来た productId が subscription_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_found | 404 | 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_found | 404 | 指定された 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_found | 404 | セッション id 未存在 / 他人のセッション | Session id does not exist or belongs to another user | P0004 |
P0004 |
履歴画面に戻る | Navigate back to history |
concurrent_session_in_progress | 409 | 既に 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_state | 409 | 状態遷移が不正(例: completed を finalize しようとした) |
Illegal state transition (e.g. finalize on completed) |
P0006 |
P0006 |
セッション詳細を再取得して UI を同期 | Refetch session detail to reconcile UI |
cancel_window_expired | 410 | 開始から 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_conflict | 409 | 同じ client_request_id で異なるペイロードを送った等、冪等性違反 |
Same client_request_id reused with a different payload |
ルート側で検出 | Detected at route layer | クライアントのキー生成ロジックを要修正 | Fix the client's key-generation logic |
subscription_required | 402 | 有料機能へのアクセスを無料プランで試みた | 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_exceeded | 409 | プランの上限(車両登録数 / 保存駐車場数 等)に到達 | 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_found | 403 | 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_inactive | 403 | 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_owner | 403 | 自分が所有していない駐車場を触ろうとした | 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_duplicate | 400 | 同じ駐車場に「審査中」の申請が既にある | 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_lot | 403 | 自分の駐車場以外のレビューに返信しようとした | 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_found | 404 | ブーストが存在しない or 自分のブーストではない | Boost does not exist or is not yours | /v1/owner/boosts/* |
/v1/owner/boosts/* |
一覧画面に戻す | Return to the boost list |
owner_invite_no_link | 403 | 招待受諾時に 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_failed | 400 | 招待受諾時のパスワード設定が Supabase Auth で失敗 | Password set failed in Supabase Auth during invite accept | /v1/owner/invitations/accept |
/v1/owner/invitations/accept |
画面でリトライを促す | Prompt the user to retry |
RAISE EXCEPTION '...' USING ERRCODE = 'P00XX' で投げるシグナルは、
lib/db.ts の translatePgError がこの表の 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);
}
throw new HTTPException(...)を直接書かない。全てショートハンド/ApiError 経由で統一する- Don't write
throw new HTTPException(...)directly — always go through a shortcut orApiError. - ルートハンドラ内で
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.ts)4. 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 |
|---|---|---|---|---|---|
ts | ts |
ISO8601 / 2026-04-19T12:34:56.789Z |
ISO8601 / 2026-04-19T12:34:56.789Z |
UTC。常に含まれる | UTC; always present |
level | level |
"debug" | "info" | "warn" | "error" |
"debug" | "info" | "warn" | "error" |
LOG_LEVEL でしきい値フィルタ(dev=debug / prod=info) |
Gated by LOG_LEVEL (dev=debug / prod=info) |
msg | msg |
string / "request completed" |
string / "request completed" |
人間可読。英語推奨(日本語も可) | Human-readable; English preferred |
scope | scope |
"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_id | request_id |
UUID | UUID | HTTP 経由は必ず付与。x-request-id と一致 |
Always set for HTTP; matches x-request-id |
err | err |
{ name, message, code?, status?, stack?, cause? } |
{ name, message, code?, status?, stack?, cause? } |
warn/error 時のみ。ApiError は code/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
-
api/src/lib/errors.tsのERROR_CATALOGに{ status, message }を追加する。命名は英語小文字スネークケース。 -
Add
{ status, message }toERROR_CATALOGinapi/src/lib/errors.ts. Name in lowercase snake_case. -
頻繁に使うならショートハンド関数(
badRequest等と同じパターン)を追加する。 -
Add a shortcut function if it'll be used frequently (follow the
badRequestpattern). -
呼出側で
throw new ApiError("new_code", "...")or ショートハンドを使う。 -
Throw via
throw new ApiError("new_code", "...")or the shortcut. -
このドキュメント(
docs/api-errors.html)の該当テーブルに行を追加する。 -
Append a row to the table in this document (
docs/api-errors.html). - クライアント側(モバイル / admin / public web)でハンドリング方針を決めて実装する。
- Decide and implement the client-side handling (mobile / admin / public web).