Skip to main content

Use the OData v4 Type-safe Client API

Prerequisites

To leverage the type-safe OData v4 client you need to have generated a client library for your OData service. See the generator documentation for how to generate such a client library.

Discover services on the SAP Business Accelerator Hub

If you would like to consume a specific service, try searching for it on the SAP Business Accelerator Hub and visit the SAP Cloud SDK tab on the landing page of that service. There you will find detailed instructions on how to use the SAP Cloud SDK to get a client library for that specific service. Learn more about the integration with the SAP Business Accelerator Hub in the blog.

Build and Execute OData Requests With the Typed OData Client

The typed OData v4 client allows building type-safe OData v4 requests for a given service. The Java classes represent the data model and the available operations of the service. As a consequence, all requests that are built through the typed OData client are not only syntactically valid but also semantically valid.

Generic OData Client

The type-safe client may in some situations not be suitable for your needs. For instance a service might not fully conform to the OData standard, forcing you to perform customized requests. Or the typed API doesn't support a specific OData feature just yet.

For such cases we recommend leveraging our low-level generic OData client. Or to fallback directly to the HTTP client level if that's not enough.

In all the other situations the type-safe client is preferable and considered best practice.

Using the Fluent API

The typed OData v4 client consists of service and data model classes. The service classes mirror the API provided by the OData service and serve as entry point for creating requests. They provide a builder that allows for fluently adding further parameters.

To execute HTTP requests the OData client uses Destinations and are documented in more detail here. The following code snippets assume that such a destination is in place:

Destination destination;

On an abstract level requests are generally build up according to the following pattern:

result = service.operation()
.withParameter(...)
.execute(destination);
  • operation corresponds to the service's capabilities for entities e.g. createEntity or readEntities.
  • withParameter corresponds to:
    • OData v4 query parameters e.g. filter or orderby
    • Or other modifiers like custom headers
  • Which OData v4 parameters are available depends on the operation. For example, when updating entities the $filter parameter is not available.

Below different OData features are documented using the Business Partner Service on SAP S/4HANA as an example. It is represented by the BusinessPartnerService class which is created by the OData generator when generating a client for this service. The following code snippets assume that a client has been generated and an instance of this service is set up:

BusinessPartnerService service = new DefaultBusinessPartnerService();

OData Features

Basic CRUD Operations

Create, Read, Update and Delete operations on entities are build from the associated service class:

service.createBusinessPartner(partner);
service.getBusinessPartnerByKey("id");
service.getAllBusinessPartner();
service.updateBusinessPartner(partner);
service.deleteBusinessPartnerAddress(address);

Each of the above statements returns a builder object that allows for specifying certain request parameters, depending on the operation.

The following query parameters and request options are available for these operations:

Query parameters:

  • $select, $expand and $search are available when reading a single value or multiple entities
  • $filter, $top, $skip and $orderby are available only when reading a collection of entities

Request parameters:

  • Update operations allow to set either modifyingEntity() or replacingEntity() which will result in HTTP PATCH or HTTP PUT respectively. By default, entities are modified via PATCH.
  • Update and delete operations allow modifying how ETags are handled:
    • By default, an ETag is sent if one is present on the entity being modified.
    • matchAnyVersionIdentifier() will instead always send a * which acts as a wildcard to match all ETags.
    • ignoreAnyVersionIdentifier() will ensure that no ETag is sent.
  • All operations allow for adding custom headers via withHeader(...)

Handling of ETags

An ETag is a version identifier that is often used to implement an optimistic locking mechanism. The SAP Cloud SDK will try to read version identifiers from responses and set them when sending OData requests.

Consider the following example:

partner = service.getBusinessPartnerByKey("id")
.execute(destination);
response = service.updateBusinessPartner(partner)
.execute(destination);
// update the partner reference
partner = response.getModifiedEntity();

On the read request, the SAP Cloud SDK will automatically try to extract the version identifier from the response and store it within the partner object. When updating it will be taken from there and sent with the If-match header.

note

If a service requires this header to be sent: Fetching the entity from the service first is essential to ensure that the ETag is present and up to date.

Select

When reading entities, the API offers select( ... ) on the builders. Through it, the query parameters $select and $expand are set. It takes in the properties of the entity being queried. Primitive properties are added to $select while complex and navigational properties are added to $expand. This handling is done automatically by the SAP Cloud SDK.

The properties that can be selected or expanded are represented via static fields on the entity class. There will be a field for each property. E.g. for the business partner entity one can find BusinessPartner.FIRST_NAME and BusinessPartner.LAST_NAME.

service.getBusinessPartnerByKey("id")
.select(BusinessPartner.FIRST_NAME, BusinessPartner.LAST_NAME, BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS)
.execute(destination);

The above translates to the following query parameters:

$select=FirstName,LastName&$expand=to_BusinessPartnerAddress

OData v4 allows for formulating nested, fully featured queries on complex and navigational properties. Querying nested objects is possible within an expand statement. That means the following query is possible:

service.getBusinessPartnerByKey("id")
.select(BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS
.select(BusinessPartnerAddress.CITY_CODE, BusinessPartnerAddress.COUNTRY)
.filter(BusinessPartnerAddress.CITY_CODE.notEqualTo("1234"))
.orderBy(BusinessPartnerAddress.COUNTRY.desc())
)
.execute(destination);

The above translates to the following expand query parameter:

$expand=to_BusinessPartnerAddress($select=CityCode,Country;$filter=CityCode eq '1234';$orderby=Country desc)

Filter

When operating on a collection of entities, the API offers filter( ... ) on the builders. It directly corresponds to the $filter parameter of the request. Filters are also built via the static property fields on entities.

The following example:

/*
Get all business partners that either:
- Have first name 'Alice' but not last name 'Bob'
- Or have first name 'Mallory'
*/
service.getAllBusinessPartner()
.filter(BusinessPartner.FIRST_NAME.equalTo("Alice")
.and(BusinessPartner.LAST_NAME.notEqualTo("Bob"))
.or(BusinessPartner.FIRST_NAME.equalTo("Mallory"))
)
.execute(destination);

Will translate to this filter parameter:

$filter=(((FirstName eq 'Alice') and (LastName ne 'Bob')) or (FirstName eq 'Mallory'))

Take note of the order of and and or. As or is invoked on the result of and it will form the outer expression while and is an inner expression in the first branch of or.

To achieve a different order with and as the top level statement one would nest the or within and(...):

.and(BusinessPartner.LAST_NAME.notEqualTo("Bob")
.or(BusinessPartner.FIRST_NAME.equalTo("Mallory"))
)

Available Filter Expressions

The OData v4 standard allows for a wide range of filter expressions. A detailed list of what is available in the SAP Cloud SDK can be obtained from the Javadoc. The functionality can also be discovered through the fluent API.

The below example leverages OData v4 exclusive features to build a more complex request:

/*
Fetch all business partners where:
- the last name is at least twice as long as the first name
- AND the combined string of first and last name does not contain 'bob'
*/
service.getAllBusinessPartner()
.filter(BusinessPartner.FIRST_NAME.length().multiply(2).lessThan(BusinessPartner.LAST_NAME.length())
.and(BusinessPartner.FIRST_NAME.concat(BusinessPartner.LAST_NAME).contains("bob"))
)
.execute(destination);

Custom Filter Expressions

The SAP Cloud SDK for Java also allows custom filter expressions. This is useful when filter fields need to be passed dynamically.

For example, to filter based on MiddleName use:

ValueBoolean filterExpression = FieldReference.of("MiddleName").equalTo("SomeName");
FilterableBoolean<BusinessPartner> customFilterExpression = FilterableBoolean.fromCustomFilter(filterExpression, BusinessPartner.class);

service.getAllBusinessPartner().filter(customFilterExpression).execute(destination);

OData Functions and Actions

OData defines bound and unbound functions and actions.

Unbound Operations

Unbound functions and actions can be performed via their respective methods on the service:

service.myFunction("paramValue1", "paramValue2")
.execute(destination);

Bound Operations

Functions and actions bound to entity types can be invoked by navigating to any instance or collection of the entity type on the service.

For functions use applyFunction:

Entity myEntity;
service.forEntity(myEntity)
.applyFunction(Entity.myFunction())
.execute(destination);

This will invoke the following HTTP call, assuming that myEntity is identified by "my-entity":

GET /service-root/Entity('my-entity')/MyNamespace.MyFunction()

Similarly, for actions use applyAction:

Entity myEntity;
service.forEntity(myEntity)
.applyAction(Entity.myAction())
.execute(destination);
Obtaining an Entity to Operate on

To get an entity object to operate on it is generally recommended to download it from the service first via its respective getByKey method. Fetching an entity will also ensure that ETag is handled if OData Action is invoked, which often leads to a change of a state of an entity.

Another way is to use a builder on an Entity object to create an instance if you are aware of the key properties that are required.

Bound Operations on Collections

Functions and actions may also be bound to collections of entities. They are also invoked via applyFunction and applyAction but on the service instance itself:

service.applyAction(Entity.myAction())
.execute(destination);

This will invoke the following HTTP call:

POST /service-root/Entity/MyNamespace.MyAction()

Furthermore, OData allows invoking bound operations not only on entities but also on navigation properties. The API allows you to navigate to any navigation property of an entity to invoke an operation:

Entity myEntity;
service.forEntity(myEntity)
.navigateTo(Entity.NAVIGATION_PROPERTY)
.applyFunction(NavigationEntity.myFunction())
.execute(destination);

This would yield:

GET /service-root/Entity('my-entity')/NavigationProperty/MyNamespace.MyFunction()

Composable Functions

Finally, functions may be composable. If IsComposable is set to true in the service metadata the service allows to use the result of a function within another operation.

Consider the following request:

GET /Entities('my-entity')/ComputeRelatedEntity()/EntityPropertyList?$top=5

The service will first perform the function ComputeRelatedEntity and then access the EntityPropertyList property of the result entity. Then the top five elements are returned.

Such a request can be built with the API as follows:

Entity myEntity;
service.forEntity(myEntity)
.withFunction(Entity.computeRelatedEntity())
.navigateTo(RelatedEntity.ENTITY_PROPERTY_LIST)
.getAll()
.top(5)
.execute(destination);

For more details on the API see also the section about operating on navigation properties directly.

Accessing Extensible or Custom Fields

The concept of In-app Extensibility allows users to introduce additional fields to an existing SAP S/4HANA service. To consume and work with such custom fields in your application code, SAP Cloud SDK introduces the concept of custom fields.

You can set custom fields on an entity through the .setCustomField method and read the value in the entity using .getCustomField(fieldName)

businessPartner.setCustomField("shoeSize", 10);

final int shoeSize = businessPartner.getCustomField("shoeSize");

If you are unsure about the custom fields added by the customer, you can still use getCustomFieldNames() to obtain the set of custom fields introduced. Use this set further on to pull values from the entity.

For e.g.

final Set<String> customfieldNames = businessPartner.getCustomFieldNames();

for(String customFieldName: customfieldNames){
final Object customField = businessPartner.getCustomField(customFieldName);
}

The accessors for custom fields respect multitenancy. getCustomFieldNames(), for example, fetches only custom field names available in the context of the current tenant.

Using Custom Fields In System Query Options

You can also pass custom fields to select or filter system query options:

service.getAllBusinessPartner()
.select(new SimpleProperty.NumericInteger<>(BusinessPartner.class, "ShoeSize"))
.select(() -> "AnotherCustomField")
.filter(new SimpleProperty.NumericInteger<>(BusinessPartner.class, "ShoeSize").equalTo(42))
.execute(destination)

Please be aware that such an example can work for all tenants only if ShoeSize and AnotherCustomField are custom fields that have been defined by all tenants.

Batch Requests

Batch requests allow wrapping multiple OData requests into one single batch call. Thereby we reduce the number of round trips to the remote server. Refer to the official OData v4 spec for further reference about batch requests, their semantics, and the request/response format.

Execute Batch Request

The service object offers the method batch which allows access to the batch-related methods that help build the batch request.

Multiple single requests (like createBusinessPartnerRequest) can be wrapped into so-called changesets. A changeset can be added by addChangeset and the corresponding create, update, or delete operations can be passed as parameters into it. You can wrap multiple changesets into one batch request. Similarly, retrieve operations can be added to the batch request by using addReadOperations.

Use execute to issue the batch request to the remote system. We receive an instance of BatchResponse as the result object.

GetAllRequestBuilder<BusinessPartner> getBusinessPartners = service.getAllBusinessPartner();
GetByKeyRequestBuilder<BusinessPartner> getBusinessPartnerByKey = ...
CreateRequestBuilder<BusinessPartner> createBusinessPartnerRequest1 = ...
CreateRequestBuilder<BusinessPartner> createBusinessPartnerRequest2 = ...
DeleteRequestBuilder<BusinessPartner> deleteBusinessPartnerRequest = ...

BatchResponse result =
service
.batch()
.addReadOperations(getBusinessPartners, getBusinessPartnerByKey)
.addChangeset(createBusinessPartnerRequest1, createBusinessPartnerRequest2, deleteBusinessPartnerRequest)
.execute(destination);

Access Batch Response

Use getModificationResult to access the response for a specific create, update, or delete operation.

ModificationResponse<BusinessPartner> createResult = response.getModificationResult(createBusinessPartnerRequest1);
ModificationResponse<BusinessPartner> deleteResult = response.getModificationResult(deleteBusinessPartnerRequest);

Similarly, use the method getReadResult to access the response for a specific retrieve request.

List<BusinessPartners> readAllResult = response.getReadResult(getBusinessPartners);
BusinessPartner readByKeyResult = response.getReadResult(getBusinessPartnerByKey);

Per the OData specification, either all operations or none within the same changeset are successful. Hence, if the changeset was a failure then evaluating the result of the contained requests will contain the failure of the changeset.

note

If all responses in a BatchResponse are evaluated using getReadResult(...) or/and getModificationResult(...), this ensures that the underlying InputStream of the HttpEntity is fully consumed and the HTTP connection is released back to the connection pool. If you do not evaluate all responses in a BatchResponse consider using the try-with-resources construct while executing batch requests.

tip

To ensure that all resources are properly closed while executing batch requests, we recommend using the try-with-resources construct.

  try(
BatchResponse result = service.batch()
.addReadOperations(readOperation)
.addChangeSet(createRequest, updateRequest, deleteRequest)
.execute(destination)
) {
List<BusinessPartner> entities = result.getReadResult(readOperation);
ModificationResponse<BusinessPartner> createResult = response.getModificationResult(createRequest);
ModificationResponse<BusinessPartner> deleteResult = response.getModificationResult(deleteRequest);
}

This is especially helpful in preventing connection leaks if you are re-using a HttpClient to execute multiple batch requests.

Entity Update Strategies

The SAP Cloud SDK supports different strategies for updating entities which differ in the HTTP method and the payload of the update request.

info

To update an entity it should first be retrieved from the service. This ensures the latest state of the entity is updated. Otherwise, updating with old data could erase changes that have been made to the entity in the meantime. Many services will enforce this behavior with optimistic locking mechanisms. Refer to the section about ETags for details.

The default strategy is the modifying entity update strategy which attempts to modify only the necessary entity fields in the remote system. It issues a PATCH request and includes only the fields in the request payload whose values were changed by its setter method. Hence, fields whose values remain unchanged are not sent to the target system. Calling the method includingFields(fields ...) instructs the SAP Cloud SDK to add the mentioned fields explicitly in the update request. You could also add navigation properties to your request using the method. This is useful for backend systems that require some unchanged fields in the request payload for given reasons.

This update strategy can be explicitly chosen by invoking the method modifyingEntity() while building the update request.

service.updateBusinessPartner(partner)
//method call optional, since active by default
.modifyingEntity()
//add the Business Partner full name and address explicitly to the update request
.includingFields(BusinessPartner.BUSINESS_PARTNER_FULL_NAME,BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS)
.executeRequest(destination);

To update a complex property use the following API

Entity entity;
entity.setComplexProperty(new ComplexProperty("Foo"));

Transitive changes inside a complex property will automatically be detected.

note

It depends on the capabilities of the specific OData service in the remote system which update strategies are supported in an end-to-end scenario. For example, there are cases where OData services do not support PUT requests.

Error Handling

Sometimes requests fail and the SAP Cloud SDK provides a flexible way to deal with such failures on multiple levels. All execute methods may throw a runtime exception (extending) ODataException. This will always contain the request which was (attempted to be) sent out as well as the cause of the exception. To handle all kind of failures consider the following code:

try { ... }
catch( final ODataException e ) {
ODataQueryGeneric query = e.getQuery();
logger.debug("The following query failed: {}", query);
// do something else
}

This handling is the most generic, handling all possible failures. For more specific information, dedicated exceptions are inheriting from ODataException. Please check the documentation for details on all the exception types.

To handle different kinds of failure one can list multiple catch clauses to cover different levels or cases that might occur, e.g.:

try { ... }
catch( ODataServiceErrorException e ) {
// handle the specific error message from the response payload
ODataServiceError odataError = e.getODataError();
logger.debug("The OData service responded with an error: {}", odataError);
} catch( ODataDeserializationException e ) {
// handle failures in deserialization
} catch( ODataResponseException e ) {
// handle all other errors originating from handling the HTTP response
int httpCode = e.getHttpCode();
Collection<Header> httpCode = e.getHttpHeaders();
Option<String> httpBody = e.getHttpBody();
}

Note that instead of applying try/catch one can also make use of tryExecute on the request builders.

A navigation property describes a unidirectional relationship between two entity types. Like other properties, it has a name and declares a multiplicity, i.e. whether to expect a single value or multiple values. Additionally, a navigation property allows for dedicated CRUD operations, that may not be exposed by default on entity sets of the service root. Such operations also provide a convenient way to access the nested resources of entities.

The typed OData client for OData v4 supports the following operations on (arbitrarily nested) navigation properties:

  • Create
  • Read
  • Update
  • Delete
  • Count

The below example leverages the creation of a nested entity in relation to an existing entity:

/*
Create a new address for a specific business partner.
*/
BusinessPartner businessPartnerById = BusinessPartner.builder().businessPartner("123").build();
BusinessPartnerAddress addressItem = BusinessPartnerAddress.builder().country("DE").build();

service.forEntity( businessPartnerById )
.navigateTo( BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS )
.create( addressItem )
.execute( destination );

This sample API call translates to the following service request:

POST /ODataService/API_BUSINESS_PARTNER/A_BusinessPartner(123)/to_BusinessPartnerAddress
{
"country": "de"
}

Server-Driven Paging

Pagination describes the practice of splitting a collection of entities from reading requests into one or many pages. The paging behavior is determined by both the server and the client.

OData service operators may decide to enable server-driven pagination to limit the amount of data that is fetched and sent over the network to the client, in case generic query options yield huge amounts of data. By splitting the result-set into sequential pages of entities, the data can be requested incrementally. This reduces initial network load and improves overall response times. If the payload of an OData response contains a link including a $skiptoken, then it indicates a next page to the result-set. The iterable result-set is a consistent snapshot of the data, it can not change between reading individual pages.

In comparison, the OData consumer may use query options $top and $skip (client-driven paging) to read partial data from the result-set, but relying on a consistent state while browsing through the data can be problematic. Between individual requests, another user could delete or add an item. This would result in an inconsistent aggregation of data.

By default, the SAP Cloud SDK automatically resolves all pages of a result-set if server-driven paging is encountered. For the API consumer, it is not necessary to parse the next link and instantiate follow-up requests to aggregate the full result-set.

List<BusinessPartner> iterablePagesOfEntities = service
.getAllBusinessPartner()
.execute( destination );

In case memory efficiency and response time of the consuming application becomes a priority, then the advanced API provides additional means to manually iterate through the internal pages. While accessing the following methods, the internal HTTP requests are executed lazily:

Iterable<List<BusinessPartner>> iterablePagesOfEntities = service
.getAllBusinessPartner()
.iteratingPages()
.execute( destination );

Iterable<BusinessPartner> iterableEntities = service
.getAllBusinessPartner()
.iteratingEntities()
.execute(destination);

Stream<BusinessPartner> streamingEntities = service
.getAllBusinessPartner()
.streamingEntities()
.execute(destination);

The request builder allows for setting the optional parameter for preferred page size, e.g.:

Iterable<List<BusinessPartner>> iterablePagesOfEntities = service
.getAllBusinessPartner()
.withPreferredPageSize(20)
.iteratingPages()
.execute( destination );
caution

Please note that the OData service is not obliged to respect this setting.

Asynchronous OData API Calls

Not yet implemented

Please, create a feature request if you interested in this feature.

The OData v4 standard has introduced means for server-side asynchronous requests. This feature is also supported in the SAP Gateway Foundation. At the moment the SAP Cloud SDK for Java does not provide a typed API to make such requests. However, you can leverage our Generic OData Client to simplify the logic of making such requests.

Process Synchronous OData Request in Asynchronous Fashion on the client side

You can leverage queueCallable from our resilience API to run multiple OData requests in asynchronous & non-blocking way on the client side.

Cross Site Request Forgery (CSRF)

Many OData server (especially SAP S/4HANA services) require proper CSRF-token handling when creating, updating, or deleting entities for security reasons. The SAP Cloud SDK for Java supports this requirement by sending a pre-flight request to obtain a valid CSRF token for the subsequent, actual request.

By default, this pre-flight request is sent automatically and every time before running a modification request.

Customizing the CSRF Token Request

Some OData services may have special requirements for the CSRF token request. For example, some SAP S/4HANA services may require an additional trailing slash / with the CSRF request.

You can customize the behavior by registering a CustomCsrfTokenRetriever that implements the desired behavior:

class CustomCsrfTokenRetriever extends DefaultCsrfTokenRetriever
{
@Nonnull
@Override
public CsrfToken retrieveCsrfToken(
@Nonnull HttpClient httpClient,
@Nonnull String servicePath, @Nonnull Map<String, Collection<String>> headers )
{
return super.retrieveCsrfToken(httpClient, servicePath + "/", headers);
}
}

And then set the CustomCsrfTokenRetriever on your request like so:

var request = xxx.toRequest();
request.setCsrfTokenRetriever(new CustomCsrfTokenRetriever());
var client = HttpClientAccessor.getHttpClient(httpDest);
return request.execute(client);
Multi-threaded OData requests not working

In multi-threaded scenarios the OData service can reject the request with a CSRF token validation failed response.

This is related to how HTTP Client Caching works in the SAP Cloud SDK. Assume we are sending multiple OData requests in parallel:

for (var i = 0; i < 10; ++i) {
ThreadContextExecutors.submit(() -> {
return service.createBusinessPartner(partner).executeRequest(destination);
});
}

In this example, all 10 requests will be executed in parallel. While doing so, the SAP Cloud SDK eventually needs an HTTP client to actually run the HTTP calls. This client is retrieved via the HttpClientAccessor and, therefore, will be the exact same instance across all 10 threads.

In itself, this is not an issue and, in fact, is the desired behavior because it performs a lot better than creating a new HTTP client instance for every call. What is a problem, however, is that the OData service, at least in the SAP S/4HANA case, sends back the CSRF token twice: Once in a special header (the x-csrf-token header) and once in a Set-Cookie header. The HTTP cookie ends up being stored within the HTTP client instance so that it can be used to carry some server-set state across multiple requests.

In our multi-threaded example, this is an issue because there is a race condition between the 10 threads. Since the SAP Cloud SDK executes two requests (the pre-flight and the actual request) for every modification request, it is possible that the pre-flight request of one thread overwrites the CSRF cookie of another thread. This can lead to an inconsistent state where the Cookie sent by the SAP Cloud SDK does no longer match the x-csrf-token header sent by the OData service. The OData service recognizes this inconsistency and rejects the request.

Solution

To avoid issues with CSRF tokens in multi-threaded scenarios, we suggest to manually fetch the CSRF token and then use it like so:

HttpClient httpClient = HttpClientAccessor.getHttpClient(destination);
CsrfToken csrfToken =
new DefaultCsrfTokenRetriever().retrieveCsrfToken(httpClient, BusinessPartnerService.DEFAULT_SERVICE_PATH);

for( var i = 0; i < 10; ++i ) {
ThreadContextExecutors.submit(() -> {
BusinessPartner newBusinessPartner;
ODataRequestResultGeneric untypedResult =
service
.createBusinessPartner(newBusinessPartner)
.withHeader(DefaultCsrfTokenRetriever.X_CSRF_TOKEN_HEADER_KEY, csrfToken.getToken())
.toRequest()
.execute(httpClient);

return ModificationResponse.of(untypedResult, newBusinessPartner, destination);
});
}

The code above solves the issue explained above by doing the following:

  1. We are pre-fetching the CSRF token once and then re-using it across all threads. That way, the SAP Cloud SDK won't run a pre-flight request and the CSRF token is going to be consistent.
  2. We are fetching the HTTP client just once and then re-use it across all threads. This avoids an edge-case issue where the HTTP client cache expires while we are still running requests.