Projektiranje otpornih API-ja: Planiranje za nepredviđeno
Most API failures are not dramatic. They arrive as a slow database query, a client retrying too aggressively, an upstream service returning malformed data, or a container restarting halfway through a deployment. The happy path may be correct, yet the system still becomes unreliable when reality applies pressure.
Resilient API design is the discipline of treating those conditions as normal operating cases. It is not about promising zero downtime or wrapping every operation in a generic try/catch. It is about making failures bounded, understandable, recoverable, and safe for both clients and operators.
Start by defining the failure boundaries
An endpoint is rarely a single action. A request can pass through a load balancer, PHP runtime, application code, cache, database, queue, and one or more external services. Each boundary has different failure modes and different recovery options.
Map the dependencies of an important endpoint before optimizing it. Ask practical questions: What happens if the database is slow? What if the payment provider times out after accepting the request? What if Redis is unavailable? What work can be retried, and what work must happen exactly once?
This exercise often reveals that “availability” is not one requirement. A product catalogue may reasonably serve slightly stale cached data. A money transfer should not silently proceed with uncertain state. Good architecture distinguishes between these cases instead of applying one availability strategy everywhere.
Make timeouts and retries deliberate
Without a timeout, an unavailable dependency can consume PHP workers until the entire API becomes unresponsive. Every outbound network call should have a bounded connection timeout and a bounded overall request timeout. The exact values depend on the user-facing latency budget and the dependency’s expected behaviour, but “wait indefinitely” is almost never a useful policy.
Retries can improve resilience, but they can also amplify an outage. If many requests time out and every caller immediately retries, the failing service receives more traffic precisely when it is least able to handle it. Retry only transient failures, limit the number of attempts, and add backoff with jitter.
function retry(callable $operation, int $attempts = 3): mixed
{
$lastException = null;
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
try {
return $operation();
} catch (TransientDependencyException $exception) {
$lastException = $exception;
if ($attempt === $attempts) {
break;
}
$baseDelayMilliseconds = 100 * (2 ** ($attempt - 1));
$jitterMilliseconds = random_int(0, 100);
usleep(($baseDelayMilliseconds + $jitterMilliseconds) * 1000);
}
}
throw $lastException;
}
This pattern is only safe when repeating the operation is safe. A GET request is commonly retryable. A request that creates an order may not be, unless the API supports idempotency.
Use idempotency to protect important writes
Clients lose responses. Mobile networks drop connections. Reverse proxies can time out even after an application has completed its work. If a client retries a write request, the server must be able to tell whether it is a new request or a duplicate.
For operations with meaningful side effects, accept an idempotency key and store it with the resulting operation record. The key should be scoped to the authenticated client and endpoint. A repeated request with the same key should return the original result, while a key reused with different request content should be rejected clearly.
The database constraint matters as much as application logic. A unique index on the appropriate identity and idempotency key prevents two concurrent PHP workers from both deciding that they should create the same record.
CREATE UNIQUE INDEX orders_client_idempotency_key_unique
ON orders (client_id, idempotency_key);
Do not hold a database transaction open while waiting on an external HTTP request. Keep transactions short: validate data, make the local state change, commit it, and then coordinate follow-up work through a durable mechanism such as a queue or outbox table.
Design responses for failure as carefully as success
A resilient API gives clients enough information to respond correctly without exposing internal details. Use consistent status codes and a stable error structure. Validation failures should identify invalid fields. Authentication and authorization failures should be distinct. Temporary dependency problems should tell clients that retrying later may be appropriate.
{
"error": {
"code": "DEPENDENCY_UNAVAILABLE",
"message": "The request could not be completed at this time.",
"request_id": "req_8f2c"
}
}
A request identifier links the client-visible failure to application logs and traces. It is far more useful than returning a raw exception message, which may leak implementation details and rarely tells a client what to do next.
Contain cascading failures
When a dependency is unhealthy, continuing to call it on every request wastes resources. A circuit breaker can temporarily stop calls after repeated failures, then allow limited probe requests after a cooling period. Even without a dedicated library, the principle is valuable: fail quickly when a dependency is known to be failing.
Bulkheads provide another layer of protection. Separate worker pools, queue consumers, connection limits, or resource budgets so a slow reporting integration cannot exhaust capacity needed for core API traffic. Docker does not automatically create this isolation; container limits and deployment settings must be chosen intentionally.
Useful operational safeguards include:
- health checks that reflect whether an instance can safely receive traffic;
- database connection limits that leave room for administrative access and other services;
- queue consumers that can tolerate duplicate messages and safely resume after a restart;
- structured logs containing request IDs, operation IDs, dependency names, and error categories;
- alerts based on sustained symptoms, such as elevated error rates or exhausted capacity, rather than every isolated exception.
Deploy for rollback, not optimism
A deployment is a failure scenario with a schedule. Make schema changes compatible with both old and new application versions whenever possible. Add a nullable column before requiring it. Deploy code that can read both representations before removing the old one. Avoid migrations that require every application instance to switch at exactly the same moment.
Containers should be replaceable, not treated as durable servers. Store state in appropriate external systems, handle termination signals so workers stop accepting new work, and ensure readiness checks prevent traffic from reaching an instance before it is prepared.
Resilience is a product of clear trade-offs
The strongest APIs are not those with the most infrastructure patterns. They are the ones whose teams know which operations may be delayed, repeated, rejected, or served from stale data. They set limits before overload forces limits upon them.
Plan the unhappy paths while the system is calm. Add timeouts, make writes idempotent, protect dependencies, and make failures observable. When the unexpected eventually arrives, the API will not need to be perfect. It will need to fail in a way that lets users, clients, and engineers recover.