あるプロジェクトのセマンティック検索は、一回ごとにお金がかかります。埋め込みが一回、リランキングの呼び出しが一回、テキストの合成が一回。リクエストあたり数セントです。ループを回す bot を掛ければ、数セントでは済みません。最初のバージョンはアカウントを一切求めませんでした。アニメを検索するのにアカウントを求めるのは馬鹿げて見えたからです。いまでも馬鹿げて見えます。それでも求めます。
理由はマーケティングでもメーリングリストでもありません。identity がなければ、数えるものが何もないからです。
身元を確かめずに通すコスト
何かを作る前に関心を測るため、五つのランディングを同時に走らせる fake-door 検証のハーネスを組みました。守る必要があったのは管理画面だけなので、ログインを何度か失敗した IP を BAN するようにしました。十分だと思いました。
十分ではありませんでした。クライアントの IP は x-forwarded-for から来ていて、このヘッダは クライアント, proxy1, proxy2… という並びです。各プロキシは右側に追加するだけで、すでに書かれていたものを誰も掃除しません。最初の値を取るというのは、クライアントが書きたいものを取るということです。誰でも x-forwarded-for: 1.2.3.4 を送って自分の BAN を回避できますし、ついでに他人の IP を書いてその人を BAN させることもできます。
左端を信じるのをやめ、ホップ数を数えることで直しました。
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map((s) => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
プロキシが一枚前にいるなら、本物のクライアントは最後の値です。プロキシが書き、クライアントが触れない値です。動きます。でも、いま何をしたか見てください。誰かを近似的に識別するために、デプロイのネットワーク構成に依存する環境変数を持ち込んだのです。明日 CDN を前に置けば数字は変わり、BAN の仕組みは黙って間違えます。壊れるのではありません。間違えるのです。
IP が identity として使えない理由
間違ったプロキシの後ろでは偽装できるという点に加えて、IP は直しようのない理由でキーとして不適切です。同じオフィスの四人が一つを共有します。スマートフォンの人は建物を出た時点で IP が変わります。モバイル網は巨大なブロックに NAT をかけます。キャリアの IP を BAN するのは街区ひとつを BAN するのと同じです。
識別子に求めるのは、同じ人には安定していて、違う人には違うことです。IP はどちらも満たしません。user_id は構造上どちらも満たします。
誰が誰かわかって初めてできること
検索では、最終的に三つの階層と、三つすべてに有限の上限を置きました。
visitor: { semanticSearchLimit: 10, historyLimit: 0 }
registered: { semanticSearchLimit: 30, historyLimit: 10 }
premium: { semanticSearchLimit: 200, historyLimit: null }
型に残したコメントが判断のすべてです。常に有限。検索のクォータを免除される階層はない。プレミアムはいちばん大きな数字であって、無限ではありません。historyLimit のほうは「無制限」として null を許します。検索のほうは許しません。検索は一回ごとが請求書だからです。
訪問者はそのまま存在し、Cookie で匿名のキーを受け取ります。ただし更新のウィンドウは誰かによって違います。登録ユーザーは UTC の暦月で更新され、プレミアムは登録の記念日で更新されます。そのためには premiumAnchorAt を保存する必要があり、何かを保存するには持ち主のいる行が必要です。アカウントがなければ、基準にする記念日もありません。
リクエストごとにコストがかかるアプリの場合
各操作が実際のお金を消費するとき、クォータは防御であることをやめて会計になります。そして会計は退屈な形で壊れます。
検索では、同一のリクエスト二つが二回数えられていました。解決策は、テキストではなくリクエストの内容のハッシュによる予約でした。正規化したクエリ、上限、露骨な内容のフラグ、そして並べ替えたフィルタ。型のコメントが、なぜ順序が効くのか(同じ集合を別の順で選んでも同じ検索である)、そしてなぜフィルタが中に入るのかを説明しています。同じテキストでもジャンルが違えば別のリクエストであり、まとめてしまえば間違った結果を返します。予約は reserved、duplicate、in_progress、exhausted のいずれかを返します。四つの状態があるのは、四つとも実際に起きたからです。
文字起こしの API では、文字数課金は最初、文字起こしと処理済みテキストを足して十で割るものでした。実際のコストを測ったところ、この行はこうなりました。
transcriptionText.length + Math.floor(processedText.length * 0.25)
/10 から * 0.25 へ。二・五倍高い。価格は 2.5 倍間違っていて、何か月もその差額を知らずに払っていました。気づくための計測を持っていなかったからです。
画像生成の SaaS では、バグは逆向きでした。日次クレジットは存在するのに、事前チェックが subCredits しか見ていませんでした。日次クレジットのあるユーザーが、残高があるのに 402 を受け取っていました。dailyCredits + subCredits で直りました。どれもユーザーの一行にあるカラムの話です。行がなければ、数えるものがないので直すバグもありません。
identity だけでも足りないとき
アカウントがあることは、有効な identity があることではありません。キーを使う API で、キーの行が持ち主のアカウントの状態より長生きしていました。オフボーディングで課金レコードが消されても、誰もキーの isActive に触っていなかったのです。キーは認証され続けました。フラグを信じるのをやめ、authenticateApiKey の中で持ち主にまだ課金レコードがあるかを再検証することで直しました。
if (!ownerBilling) {
return { authenticated: false, error: "API key owner is no longer active" };
}
各エンドポイントではなくそこに置くのは、まさにどのエンドポイントも覚えておかなくて済むようにするためです。
アカウントなしで通す場合
操作にコストがかからず、状態も溜まらないとき。詳細を読む、カタログを見る、ランディングを読み込む。そして短いクォータ付きの訪問者階層。決める前に試せるようにするためです。登録前に十回の検索は誠実な摩擦です。ゼロは白紙の請求書です。
問いは、ログインが煩わしいかどうかではありません。煩わしいです。問いは、誰かが度を超したときに WHERE へ何を書くのか、です。
