Error Handling
Every operation declares a checked IOException, thrown when the request cannot be executed (network failure, timeout, and so on). In addition, non-successful HTTP responses are mapped to unchecked exceptions:
| HTTP status | Exception |
|---|---|
| 401 | UnauthorizedException |
429 from signIn or completeSignIn |
SignInLockedException or SignInRateLimitedException |
| 503 | ServiceUnavailableException |
| any other non-2xx | ClientException |
Philter explains a rejected request in the response body, and the ClientException message carries it (truncated at 512 characters) after the status code, so a 400 or a 403 says what was actually wrong:
Unknown error: HTTP 400: {"message":"'strategy' must be CRYPTO_REPLACE or FPE_ENCRYPT_REPLACE."}
UnauthorizedException and ServiceUnavailableException carry what ClientException does: getStatusCode() (401 or 503), getErrorMessage(), getReason(), and getRequestId(). Their message is Philter's message, or Unauthorized or Service unavailable when the response has none, followed by the request id when the response has one; getErrorMessage() and getReason() are then null.
A 401's getReason() tells apart why the credentials were refused, so a user interface can, for example, send a person whose session ended back to sign in:
getReason() |
Meaning |
|---|---|
invalid_credentials |
The key is missing, malformed, or unknown, or is a revoked long-lived key. |
session_expired |
The session key expired, was revoked, or signed out. Sign in again. |
user_deactivated |
The key's user is deactivated. |
try {
client.listPolicies(null, null);
} catch (UnauthorizedException e) {
if (ErrorReasons.SESSION_EXPIRED.equals(e.getReason())) {
// Show the sign-in page.
}
}
Neither is a subclass of ClientException, so a catch (ClientException e) does not catch a 401 or a 503, and a multi-catch of ClientException | ServiceUnavailableException compiles. All three implement PhilterError, which has the four accessors, to handle any Philter error the same way:
import ai.philterd.philter.model.exceptions.PhilterError;
try {
client.listPolicies(null, null);
} catch (RuntimeException e) {
if (e instanceof PhilterError) {
PhilterError error = (PhilterError) e;
log.warn("Philter refused the request: {} {} ({}, request id {})", error.getStatusCode(),
error.getErrorMessage(), error.getReason(), error.getRequestId());
}
throw e;
}
SignInLockedException and SignInRateLimitedException are subclasses of ClientException, so a handler for ClientException still catches them. See Failed sign-ins.
import ai.philterd.philter.model.FilterResponse;
import ai.philterd.philter.model.exceptions.ServiceUnavailableException;
import ai.philterd.philter.model.exceptions.UnauthorizedException;
import ai.philterd.philter.model.exceptions.ClientException;
import java.io.IOException;
try {
FilterResponse response = client.filter("my-context", "default", "Some text...");
System.out.println(response.getFilteredText());
} catch (UnauthorizedException e) {
// Bad or missing API key.
} catch (ServiceUnavailableException e) {
// Philter is temporarily unavailable; consider retrying with backoff.
} catch (ClientException e) {
// Other non-successful response.
} catch (IOException e) {
// The request could not be completed.
}
Acting on the reason
Every error Philter returns is a JSON object with a message, a reason, and, for some invalid requests, a field. ClientException exposes them as getErrorMessage(), getReason(), and getField(), alongside the HTTP status as getStatusCode().
getReason()is a stable, machine-readable code. Branch on it: it tells apart refusals that share a status, and it does not change when the wording does.ErrorReasonshas a constant for every reason Philter gives. Philter gives one on every error: a specific reason where the refusal has one, and otherwise the one its status implies, such asErrorReasons.NOT_FOUNDfor a404.getErrorMessage()is written for people, so show it rather than branching on it. It is read from the whole body, so it is complete even when the exception message was truncated.getField()names the parameter or body field Philter found invalid, such assort,password, oremail, on some400responses whose reason isinvalid_request, so a form can mark the field. It isnullotherwise.
All three are null for a response that did not come from Philter, such as a proxy's error page; fall back to the status then. Compare with ErrorReasons.X.equals(e.getReason()), which is safe when the reason is null.
import ai.philterd.philter.model.exceptions.ErrorReasons;
try {
client.changePassword(currentPassword, newPassword);
} catch (ClientException e) {
if (ErrorReasons.WRONG_PASSWORD.equals(e.getReason())) {
// The current password is not correct.
} else if (ErrorReasons.INVALID_REQUEST.equals(e.getReason())) {
// The new password was rejected; show the person why.
System.out.println(e.getErrorMessage());
} else if (ErrorReasons.CHANGED_CONCURRENTLY.equals(e.getReason())) {
// Another request changed the password first; try again.
} else {
// Such as missing_scope, or a 409 conflict for a user with no password yet, whose first one an
// administrator sets.
throw e;
}
}
The reasons for each call's own refusals are below; ErrorReasons documents every reason, including the general ones such as missing_scope, admin_required, session_expired, and not_found, and Philter's Errors documentation lists them with their statuses.
| Call | Status | getReason() |
|---|---|---|
createContext |
409 |
context_exists: the caller already has a context with that name, even at the limit. context_limit_reached: the caller already has the most contexts a user may have. |
savePolicy |
409 |
policy_exists: the owner already has a policy with that name. Replace it with replacePolicy. |
replacePolicy |
409 |
policy_managed: a managed policy cannot be replaced; copy it with copyPolicy and change the copy. policy_changed: the policy changed since the read whose entity tag you passed, or while the request was writing; read it again and retry. |
deletePolicy |
409 |
policy_default: the default policy cannot be deleted. policy_managed: a managed policy cannot be deleted. |
copyPolicy |
409 |
policy_exists: the owner already has a policy with the new name. |
rollbackPolicy |
409 |
policy_managed: a managed policy cannot be rolled back. policy_changed: the policy changed since the read whose entity tag you passed, or while the request was writing; read it again and retry. |
setPolicyDetails |
409 |
policy_managed: a managed policy's description and notes cannot be changed. policy_changed: the policy changed since the read whose entity tag you passed, or while the request was writing; read it again and retry. |
saveList |
409 |
list_exists: the owner already has a list with that name. Replace it with replaceList. |
createHold |
409 |
hold_exists: a hold with that reference exists. operation_in_progress: an evidence or hold operation for the owner is active or needs recovery. |
deleteHold |
409 |
operation_in_progress, as for createHold. |
replaceRedactList |
409 |
redact_list_changed: the list changed after the revision you passed. Read it again and retry. |
testWebhook |
409 |
webhook_not_set: no webhook is set. |
getLedgerExport |
422 |
entry_unreadable: an entry in the chain could not be read, so the chain is not exported. |
signIn |
429 |
locked: the username is locked after repeated failures. rate_limited: the client address is over the sign-in rate limit. These are raised as SignInLockedException and SignInRateLimitedException. |
completeSignIn |
429 |
rate_limited, raised as SignInRateLimitedException. The MFA step has no username lockout. |
try {
client.createContext("tenant-a", false, false);
} catch (ClientException e) {
if (ErrorReasons.CONTEXT_LIMIT_REACHED.equals(e.getReason())) {
// Offer to delete a context first.
} else if (ErrorReasons.CONTEXT_EXISTS.equals(e.getReason())) {
// Choose another name.
} else {
throw e;
}
}
getStatusCode() is 0 when the exception was not caused by a non-successful status, such as a response the client could not use. SignInLockedException and SignInRateLimitedException report 429.
Request ids
Philter gives every API request an id and returns it in the X-Request-Id response header, on successes and refusals alike. It records the same id as request_id on every audit event the request causes, so the id ties what a client saw to what Philter recorded. Log it with errors, and quote it when asking an administrator, or Philterd support, about a request.
Every exception built from a Philter response carries it: ClientException (and so SignInLockedException and SignInRateLimitedException), UnauthorizedException, and ServiceUnavailableException all have getRequestId(), and their messages end with it, so a logged stack trace carries it without extra code:
Unknown error: HTTP 409: {"message":"A policy with this name already exists.","reason":"policy_exists"} (request id: 01929f2e-6c3a-7b1d-9e4f-3a2b1c0d9e8f)
getRequestId() is null when the response had none, as from a Philter version before 4.0, or when the exception was not caused by a response. getErrorMessage() is unchanged: it is Philter's message alone, without the id, for showing to people.
For a call that succeeded, getLastRequestId() returns the id of the most recent response the client received on the calling thread. Read it right after the call. It is kept per thread, so calls other threads make with the same client do not change it, and it is null when the thread's last call got no response, such as a connection failure.
try {
client.replacePolicy("claims", policyJson);
log.info("Replaced the claims policy (request id {})", client.getLastRequestId());
} catch (ClientException e) {
log.warn("Philter refused the change: {} (request id {})", e.getErrorMessage(), e.getRequestId());
}
An administrator finds the request's audit events by their request_id, in the audit log's CSV export or with getAuditLog:
GetAuditLogResponse page = admin.getAuditLog(null, from, to, null, 0, 100);
page.getEvents().stream()
.filter(event -> requestId.equals(event.getRequestId()))
.forEach(event -> System.out.println(event.getEvent() + " " + event.getDetails()));
Redaction events are the exception: document_redaction_initiated and document_redaction_completed record the document id as their request_id, so a queued redaction's start and completion share one id. Find those by the document id, from FilterResponse.getDocumentId().