When you build a Guzzle Client with base_uri and default options, how are relative paths resolved, and what timeout applies if you set none?
answer
- client defaults merge into each request
- RFC 3986 reference resolution
- trailing slash on the base path
- leading slash replaces the path
- timeout 0 waits indefinitely
basics
~20 sGuzzle resolves a request URI against base_uri by RFC 3986: 'rates' after 'https://api.test/v2/' gives /v2/rates, but '/rates' or a base without the trailing slash drops v2. The timeout option defaults to 0, which waits indefinitely.
solid answer
~40 s`new Client([...])` takes `base_uri` plus any request options, and those options become defaults for every request, which can override them per call. Relative URIs are merged with `base_uri` by RFC 3986 rules, not by string concatenation. With `https://rates.example.test/v2/`, the path `latest` becomes `/v2/latest`. A leading slash (`/latest`) replaces the whole path, and a base without the trailing slash (`.../v2`) treats `v2` as a file and replaces it. An absolute URL ignores the base. Timeouts are not set for you: `timeout` defaults to `0`, meaning wait indefinitely, and `connect_timeout` defaults to `0`, which the docs describe as waiting 300 seconds. So I always set both on the client, for example `'connect_timeout' => 2, 'timeout' => 5`. I also use `query` for parameters and `json` for request bodies.
code
php · 19 lines<?php
declare(strict_types=1);
use GuzzleHttp\Client;
$rates = new Client([
'base_uri' => 'https://rates.example.test/v2/', // trailing slash
'connect_timeout' => 2,
'timeout' => 5,
'headers' => ['Accept' => 'application/json'],
]);
// GET https://rates.example.test/v2/latest?base=EUR&symbols=USD
$response = $rates->request('GET', 'latest', [
'query' => ['base' => 'EUR', 'symbols' => 'USD'],
'timeout' => 3, // per-request override of the client default
]);
$data = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);go deeper
Know that constructor options become per-request defaults, that the trailing slash on base_uri matters, and that timeout defaults to 0, meaning no limit.
Explain RFC 3986 resolution with the leading-slash and trailing-slash cases, per-request overrides, and how query and json build the request.
Build one configured client per upstream with timeouts sized to the caller's budget, and catch the base_uri path bug in review before it sends traffic to the wrong endpoint.
Standardise how services construct HTTP clients so timeouts and base paths are never left at library defaults, and make the defaults visible in configuration.
## A client is a bundle of defaults **Guzzle** is a PHP HTTP client library built on top of cURL and PHP streams. Its `GuzzleHttp\Client` is configured once, in the constructor, and is **immutable** afterwards: the docs say you cannot change its defaults once it exists. The constructor array holds three kinds of entries: - `base_uri`: the URI that relative request URIs are resolved against; - `handler`: the callable that actually sends requests (by default a `HandlerStack` wrapping a cURL handler); - **everything else**: default request options, such as `timeout`, `headers` or `http_errors`, merged into every request. An option passed to `request()` for one call overrides the client default. ## How base_uri is resolved Guzzle does not glue strings together. It applies the reference-resolution rules of RFC 3986, section 5.2, the same rules a browser uses for a relative link. The effect depends on two slashes: | base_uri | request URI | result | |---|---|---| | `https://rates.example.test/v2/` | `latest` | `https://rates.example.test/v2/latest` | | `https://rates.example.test/v2` | `latest` | `https://rates.example.test/latest` | | `https://rates.example.test/v2/` | `/latest` | `https://rates.example.test/latest` | | `https://rates.example.test/v2/` | `https://other.example.test/x` | `https://other.example.test/x` | The rules behind the table: 1. A request URI starting with `/` is an absolute path: it replaces the base path entirely. 2. A relative path is merged with the base path **up to its last slash**. Without a trailing slash, the last segment (`v2`) is treated like a file name and dropped. 3. A full URL with a scheme ignores `base_uri` completely. The classic bug is a client with `'base_uri' => 'https://api.test/v2'` and calls to `'/rates'`: every request silently goes to `/rates`, not `/v2/rates`. Put the trailing slash on the base, and no leading slash on the paths. ## Timeouts are off unless you set them From Guzzle's request-options documentation: | Option | Meaning | Default | |---|---|---| | `timeout` | total time for the request, in seconds (float allowed) | `0`, wait indefinitely | | `connect_timeout` | time to establish the connection | `0`, which the docs describe as waiting 300 seconds | | `read_timeout` | per-read limit on a streamed body (`stream => true`) | the `default_socket_timeout` ini value | So a fresh `new Client()` will wait indefinitely for an upstream that accepts the connection and then hangs. For an exchange-rate provider behind a checkout, that means a stuck PHP worker. Set both limits as client defaults, and tighten them per request where a call has a smaller budget. When a limit expires with the default cURL handler, Guzzle throws `GuzzleHttp\Exception\ConnectException`. ## Options you will use on nearly every call - `query`: an array (or string) that becomes the query string. The docs warn that it **overwrites** any query string already in the URI; it does not merge with it. - `json`: any value `json_encode()` accepts. Guzzle encodes it as the body and adds `Content-Type: application/json` if no content type is set. It cannot be combined with `body`, `form_params` or `multipart`, and it does not take `json_encode()` flags; for custom flags, encode yourself and pass `body`. - `headers`: default headers such as `Accept` or an API key. A client-level `User-Agent` is added automatically if you set none. - `http_errors`: `true` by default, so 4xx and 5xx responses throw exceptions. ## How per-request options merge with client defaults The merge is **shallow**: an option given to `request()` replaces the client default of the same name, and an option set to `null` is removed. Headers are the exception. Client-level `headers` are applied as conditional defaults: each default header is added only if the request does not already set a header of that name. So a per-request `headers` array adds to the defaults rather than wiping them, and passing `'headers' => null` drops the defaults for that call. This is why the usual pattern works: API keys and `Accept` live on the client, and a single call adds an `Idempotency-Key` or overrides `timeout` without repeating the rest. ## One client per provider, reused Create the client once and reuse it, for example as a service in your container, instead of calling `new Client()` inside every method. The client owns its handler stack, and Guzzle's cURL handlers keep released handles for reuse, so a long-lived client can reuse connections within the PHP process. A fresh client per call starts from nothing each time and scatters configuration across call sites. ## Putting it together A client built for one provider usually sets `base_uri`, both timeouts, and the headers every call needs. Callers then pass only what varies: a relative path, `query`, or `json`. That keeps the per-call code short, and the risky defaults are handled in one place.
- With base_uri 'https://api.test/v2/', where does a request to '/latest' go, and why?To `https://api.test/latest`. Guzzle resolves request URIs by RFC 3986, and a path that starts with `/` is an absolute path that replaces the base path. To stay under `/v2/`, pass `latest` without the leading slash.
- What happens if you pass both a query string in the URI and the query option?The `query` option wins: Guzzle's docs say values in `query` overwrite all query string values supplied in the URI. A request to `latest?base=EUR` with `'query' => ['symbols' => 'USD']` is sent as `latest?symbols=USD`, so put all parameters in one place.
- Can you change a Guzzle client's timeout after it is created?Not on the client itself: Guzzle clients are immutable, and their defaults are fixed at construction. Pass `timeout` in the options of a single request to override it for that call, or build a second client with different defaults.
saying these in an interview costs you the question
- Guzzle concatenates base_uri and the path, so slashes do not matter.
- A new Guzzle client times out after 30 seconds by default.
- The query option merges with a query string already in the URI.
- You can call a setter to change a Guzzle client's default timeout later.
- The json option accepts json_encode flags such as JSON_PRETTY_PRINT.