6. Acceso sin restricción a flujos de negocio sensibles
Cada petición es legítima por separado, y juntas hacen daño. Un script que compra todas las entradas, o que crea diez mil cuentas de prueba.
Aquí: cada licencia creada cuesta algo en el proveedor. Un consumidor, comprometido o simplemente con un bug, que crea licencias en un bucle está gastando dinero real a través de una API perfectamente válida, autenticada y con límite de peticiones.
Qué lo impide: el límite por minuto ayuda pero no es la herramienta correcta, porque sesenta licencias por minuto durante un día siguen siendo un desastre. El control es un límite de negocio: license_limit en el consumidor, comprobado en store (Capítulo 7), en el request de lotes y otra vez en cada job del lote (Capítulo 18).
Esa comprobación lee un recuento y después actúa según él, y dos peticiones que llegan juntas pueden leer ambas «cuarenta y nueve». Donde el límite es una promesa firme y no una barandilla, toma un bloqueo alrededor de la comprobación y la creación:
// app/Http/Controllers/LicenseController.php
public function store(
StoreLicenseRequest $request,
): JsonResponse {
$lock = "license-create:{$request->user()->id}";
$license = Cache::lock($lock, 120)->block(
5,
fn () => $this->createWithinLimit($request),
);
return LicenseResource::make($license)
->response()
->setStatusCode(Response::HTTP_CREATED);
}
private function createWithinLimit(
StoreLicenseRequest $request,
): License {
$consumer = $request->user();
$domain = $request->validated('domain');
$held = Licenses::all();
$license = $this->holding($held, $domain);
if ($license === null) {
if ($held->count() >= $consumer->license_limit) {
throw new LicenseLimitReached;
}
$license = Licenses::create(
name: $request->validated('name'),
domain: $domain,
);
LicenseIssued::dispatch($license, $consumer);
}
return $license;
}
Este es store tal como lo deja el libro: la búsqueda del Capítulo 8, el límite del Capítulo 7, el evento del Capítulo 18 y el bloqueo alrededor de los tres. CreateLicense toma el mismo bloqueo, con el mismo nombre, como mostró el Capítulo 18. Si el bloqueo no se consigue en cinco segundos, block() lanza una excepción, y esa recibe la respuesta que el Capítulo 8 ya definió:
// bootstrap/app.php, dentro de withExceptions()
$exceptions->render(function (LockTimeoutException $e) {
return response()->json([
'message' => __('errors.request_in_progress'),
'code' => ErrorCode::RequestInProgress,
], Response::HTTP_CONFLICT, ['Retry-After' => 1]);
});
$exceptions->dontReport(LockTimeoutException::class);
La última línea deja un momento de mucha actividad fuera del rastreador de errores. Una creación disputada es el 409 de un consumidor, no un incidente.
Queda una limitación en todo esto, y el Capítulo 9 la nombró: el recuento sale de una lista que un refresco en segundo plano puede volver a dejar desactualizada por un instante. Donde el límite es un contrato y no una barandilla, lee la lista sin caché dentro del bloqueo.
Cómo lo sabes: pregunta de cada endpoint que crea algo: «¿qué nos cuesta el número mil en una hora, y lo notaría alguien?». Si nadie lo notaría, añade al panel del Capítulo 13 las licencias creadas por consumidor y por día, y alerta cuando uno se salga de su rango habitual.