Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Tests

on:
pull_request:
branches:
- main
push:
branches:
- main

concurrency:
group: tests-${{ github.head_ref || github.ref }}
cancel-in-progress: true

jobs:
test:
name: Java ${{ matrix.java }}
runs-on: ubuntu-latest
timeout-minutes: 15

strategy:
fail-fast: false
matrix:
java: ['17', '21']

steps:
- name: Traer el codigo
uses: actions/checkout@v4

- name: Preparar Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven

- name: Generar, compilar, revisar la API publica y correr los tests
run: mvn -B verify
192 changes: 192 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Caerus SDK para Java

El cliente oficial de Caerus para Java. Reserva stock limitado (asientos, cupos, turnos,
inventario) y coordina locks distribuidos sin escribir la lógica de concurrencia que evita
que dos clientes compren o modifiquen lo mismo al mismo tiempo.

```java
ResourceHolder holder = caerus.unitary("butaca_A12").take();
chargeCard(4500);
caerus.confirm(holder.id());
```

Es el mismo producto que [`@caerus-dev/sdk`](https://github.com/caerus-dev/caerus-sdk-ts):
mismos métodos, mismos errores y mismos bordes. Lo que cambia es lo que tiene que cambiar
para que se sienta Java.

**Corre en tu servidor.** La API Key autentica cada llamada e identifica tu entorno, así
que nunca tiene que llegar a un cliente o a un navegador.

---

## Instalación

Requiere Java 17 o superior.

```xml
<dependency>
<groupId>io.github.caerus-dev</groupId>
<artifactId>caerus-sdk</artifactId>
<version>0.1.0</version>
</dependency>
```

Trae gRPC (con Netty incluido), protobuf y Gson. Nada de eso aparece en la API pública: un
test del build lo hace cumplir.

---

## Conexión

```java
CaerusClient caerus = new CaerusClient(CaerusClientOptions.builder()
.apiKey(System.getenv("CAERUS_API_KEY"))
.build());
```

Esa es toda la configuración. La API Key identifica el entorno, así que el cliente ya sabe
adónde conectarse.

Un cliente por proceso: el canal multiplexa todas las llamadas. Implementa `AutoCloseable`;
`close()` es idempotente y después de cerrar toda llamada falla con un `CaerusError`.

### Apuntar a otro motor

```java
CaerusClient caerus = new CaerusClient(CaerusClientOptions.builder()
.endpoint("localhost:9090")
.apiKey(apiKey)
.tls(false)
.build());
```

| Opción | Default | Regla |
|---|---|---|
| `apiKey` | — | Obligatoria. Nunca se lee del entorno ni aparece en `toString()` |
| `endpoint` | `CAERUS_ENDPOINT`, si no `caerus.dev.ar.sdk.apps.disilab.ar:443` | `host:puerto`, sin esquema |
| `tls` | `CAERUS_TLS`, si no `true` | En la variable solo `false` o `0` lo apagan |
| `timeoutMs` | `10000` | Deadline de cada llamada |
| `logger` | `System.err` con prefijo `[caerus]` | Solo para lo que no conviene lanzar |

El `DlsClient` lee `CAERUS_DLS_ENDPOINT` y `CAERUS_DLS_TLS`, y su prefijo es `[caerus-dls]`.

---

## Shared Resource Engine

```java
caerus.createMultiple("entrada", "general", 500, CreateResourceOptions.builder()
.groupKey("funcion_20")
.build());

ResourceHolder holder = caerus.pooled("general").takeMany(2, TakeOptions.builder()
.idempotencyKey(orderId)
.ttlSeconds(600)
.metadata(Map.of("orderId", orderId))
.build());

try {
pay(holder);
caerus.confirm(holder.id(), ConfirmOptions.builder().metadata(Map.of("paymentId", paymentId)).build());
} catch (PaymentFailed e) {
caerus.release(holder.id());
}
```

| Método | Qué hace |
|---|---|
| `createUnitary(template, key)` / `createMultiple(template, key, amount)` | Declara un recurso |
| `updateResource(key, delta)` | Suma o resta stock |
| `deleteResource(key)` | Lo borra; falla si alguien lo tiene tomado |
| `unitary(key).take()` | Toma la única unidad |
| `pooled(key).take()` / `.takeMany(n)` | Toma una o varias |
| `confirm(id)` | Las deja tomadas para siempre |
| `release(id)` | Las devuelve |
| `extend(id, extraMs)` | Corre el vencimiento, en **milisegundos** |
| `getResourceHolder(id)` | Lee un holder; es el único que devuelve un `EXPIRED` sin lanzar |
| `getResource(key)` / `getResourcesByGroup(group)` / `listResourceHolders()` | Consultas |

Un `take` o un `extend` que vuelven en un estado que no sirve (por ejemplo, una clave de
idempotencia cuyo holder ya terminó) lanzan `ConflictError`: nunca se devuelve un holder que
no podés usar. Un estado que el SDK no conoce también lanza: no se adivina.

`QUEUED` es real: la plantilla encola y no había stock. El SDK no espera a que se asigne;
devuelve el holder `QUEUED` y decidís vos.

## Distributed Lock Service

```java
DlsClient dls = new DlsClient(DlsClientOptions.builder().apiKey(apiKey).build());

String result = dls.withTransaction(tx -> {
LockHolder lock = tx.acquireLock("cuentas", "cuenta-42", LockMode.EXCLUSIVE,
AcquireLockOptions.builder().idempotencyKey(requestId).timeoutMs(30_000L).build());
return transfer(lock.fencingToken().getAsLong());
}, TransactionOptions.builder().timeoutMs(20_000L).build());
```

`withTransaction` abre la transacción, la renueva sola mientras el callback trabaja y suelta
todos los locks al final, salga bien o salga mal. Si la renovación falla sin remedio, la
transacción se da por perdida: se cancela `tx.signal()`, se avisa a `onTransactionLost` y la
llamada lanza ese error aunque el callback haya terminado bien. Las excepciones checked del
callback salen tal cual, sin envolver.

Un lock denegado nunca vuelve como valor: es `LockDeniedError`. Una espera en la cola se
corta con un `AbortSignal` (`AbortController.abort()`), y `onQueued` avisa una sola vez.

## Webhooks

```java
CaerusEvent event = caerus.webhooks().constructEvent(rawBody, request.getHeader("Caerus-Signature"), secret);

if (event.data() instanceof ResourceTakenData taken) {
log.info("holder {} tomó {} unidades", taken.holderId(), taken.amount());
}
```

Verifica la firma HMAC-SHA256 contra el cuerpo crudo, en tiempo constante, con 5 minutos de
tolerancia. Cada tipo de evento tiene su `record`; un evento que el SDK todavía no conoce
llega como `UnknownEventData` en vez de romper al consumidor.

## Errores

Todo lo que lanza el SDK extiende `CaerusError`, que es unchecked: un solo
`catch (CaerusError e)` los junta a todos, y `e.code()` sirve para un `switch`.
`e.reason()` trae el código del motor (`OUT_OF_STOCK`, `DEADLOCK_DETECTED`...) y
`e.requestId()` el identificador para pedir soporte. Detalle en
[docs/errores.md](docs/errores.md).

El SDK no reintenta nada. Reintentar es decisión de quien llama, con clave de idempotencia.

## Testing sin Caerus

`InMemoryCaerusClient` e `InMemoryDlsClient` implementan `SharedResourceApi` y `DlsApi`, así
que el código bajo test no se entera.

```java
InMemoryCaerusClient caerus = new InMemoryCaerusClient(MockOptions.builder()
.resources(List.of(MockResourceSeed.of("butaca_A12", 1)))
.build());

caerus.failNext(MockMethod.CONFIRM, new ConflictError("pago rechazado"));
caerus.advanceTime(600);
```

El tiempo no pasa solo: lo mueve el test. Son thread-safe.

---

## Desarrollo

```bash
mvn verify
```

Genera el cliente gRPC desde `proto/`, compila y corre los tests contra un motor gRPC falso
en `127.0.0.1`. La verificación contra un motor real está en
[docs/verificacion-en-vivo.md](docs/verificacion-en-vivo.md) y las diferencias con el SDK
de TypeScript en [docs/paridad.md](docs/paridad.md).

## Licencia

MIT
110 changes: 110 additions & 0 deletions docs/errores.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Errores

Todo lo que lanza el SDK extiende `CaerusError`, que extiende `RuntimeException`. Un solo
`catch` los junta a todos, y cada uno lleva un `code()` para usar en un `switch` sin
depender de `instanceof`.

```java
try {
caerus.pooled("curso-3").takeMany(1, TakeOptions.builder().idempotencyKey(k).build());
} catch (ConflictError e) {
return "no hay lugar";
}
```

Las excepciones de configuración (una API Key vacía, un `timeoutMs` negativo) son
`IllegalArgumentException` y salen al construir el cliente, no en la primera llamada. Usar
un `TransactionContext` después de que terminó `withTransaction` es
`IllegalStateException`.

## El mapeo

Refleja lo que hace el servidor en `GrpcGlobalExceptionHandler`, no una convención general
de gRPC.

| Estado gRPC | Error del SDK | `code()` | Cuándo |
|---|---|---|---|
| `NOT_FOUND` | `ResourceNotFoundError` | `RESOURCE_NOT_FOUND` | El recurso, la plantilla o el holder no existen |
| `FAILED_PRECONDITION` | `ConflictError` | `CONFLICT` | El estado no permite la operación |
| `INVALID_ARGUMENT` | `ValidationError` | `VALIDATION` | El pedido está mal formado |
| `UNAUTHENTICATED` | `AuthenticationError` | `AUTHENTICATION` | API Key ausente, desconocida o revocada |
| `DEADLINE_EXCEEDED` | `TimeoutError` | `TIMEOUT` | La llamada pasó su deadline |
| cualquier otro | `CaerusError` | `UNKNOWN` | Incluye `INTERNAL` |

## `ConflictError` junta varias cosas, y se distinguen

| Subclase | `reason()` | Cuándo |
|---|---|---|
| `OutOfStockError` | `OUT_OF_STOCK` | No quedan unidades libres |
| `HolderNotActiveError` | `HOLDER_NOT_ACTIVE` | El holder está `RELEASED`, `CONFIRMED` o `EXPIRED` |
| `ResourceHasActiveHoldsError` | `RESOURCE_HAS_ACTIVE_HOLDS` | No se puede borrar: alguien lo tiene tomado |
| `ResourceHasQueuedRequestsError` | `RESOURCE_HAS_QUEUED_REQUESTS` | No se puede borrar: hay gente en la cola |

Las cuatro siguen siendo `ConflictError`. Cualquier error trae `reason()` con el código tal
cual lo mandó el motor (`Optional` vacío si no mandó ninguno). El mensaje está escrito para
personas y puede cambiar; `reason()` es contrato.

## Errores del DLS

Todos extienden `DlsError`, que extiende `CaerusError`. Primero decide el `reason`, después
el estado gRPC.

| Subclase | `reason()` | Estado gRPC sin `reason` | Cuándo |
|---|---|---|---|
| `DeadlockAbortedError` | `DEADLOCK_DETECTED` | — | El detector eligió esta transacción como víctima |
| `LockDeniedError` | `LOCK_DENIED` | — | El lock lo tiene otro y la plantilla no encola |
| `LockAlreadyHeldError` | `LOCK_ALREADY_HELD_EXCLUSIVELY` | `ALREADY_EXISTS` | La transacción ya tiene ese lock |
| `TransactionNotActiveError` | `TRANSACTION_NOT_ACTIVE` | — | La transacción terminó o venció |
| `LockModeMismatchError` | `LOCK_MODE_MISMATCH` | — | El modo no corresponde a la plantilla |
| `LockAcquisitionCancelledError` | `LOCK_ACQUISITION_CANCELLED` | `CANCELLED` | Se canceló la espera |
| `DlsConflictError` | — | `ABORTED`, `FAILED_PRECONDITION` | Otro conflicto |
| `DlsNotFoundError` | — | `NOT_FOUND` | La transacción o el lock no existen |
| `DlsValidationError` | — | `INVALID_ARGUMENT` | Pedido mal formado |
| `DlsAuthenticationError` | — | `UNAUTHENTICATED` | API Key inválida |
| `DlsTimeoutError` | — | `DEADLINE_EXCEEDED` | Se pasó el deadline |
| `DlsError` | — | otro | `UNKNOWN` |

El motor manda un `DENIED` como respuesta exitosa del stream, no como error gRPC. El SDK no
lo devuelve como valor: `acquireLock` lanza `LockDeniedError` con `reason()` `LOCK_DENIED`.

Un `acquireLock` sin `timeoutMs` propio usa el deadline del cliente (10 s por defecto),
también dentro de `withTransaction`. Un lock en cola más tiempo que eso termina en
`DlsTimeoutError`.

## `requestId` y soporte

Cada llamada manda un `x-request-id` (`req_` + UUID) y el motor lo devuelve en los trailers.
Todo error lo trae en `requestId()` y lo agrega al mensaje:

```java
try {
caerus.unitary("butaca_1").take();
} catch (CaerusError e) {
log.error("Caerus {} ({}) request {}", e.code(), e.reason().orElse("-"), e.requestId().orElse("-"));
}
```

Con ese ID el equipo encuentra la traza exacta en los logs del motor sin que tengas que
compartir claves ni datos. `docUrl()` apunta a este documento.

## `TimeoutError` no significa que no pasó nada

Significa que la llamada pasó su deadline. Si el servidor llegó a hacer el trabajo, no se
sabe. Reintentar con la misma clave de idempotencia devuelve el holder que ya existía si
sigue `PENDING` o `QUEUED`. Si ya terminó (`CONFIRMED`, `RELEASED` o `EXPIRED`), el motor
lo devuelve igual pero el SDK lanza `ConflictError`: nunca te entrega un holder que no
podés usar. Para ver en qué quedó, usá `getResourceHolder`.

En el DLS pasa lo mismo con `acquireLock`: repetir la misma clave de idempotencia en la
misma transacción devuelve el mismo lock (mismo `lockId` y fencing token); pedir el mismo
lock con otra clave da `LockAlreadyHeldError`.

## El SDK no reintenta

Ninguna operación se reintenta sola, ni siquiera las lecturas. En un sistema de reservas los
reintentos silenciosos son exactamente cómo se entrega la misma butaca dos veces.

## Llamadas interrumpidas

Si el hilo que espera una llamada se interrumpe, el SDK cancela la llamada gRPC, vuelve a
marcar la interrupción del hilo y lanza `CaerusError` (o `DlsError`) con código `UNKNOWN`.
Loading
Loading