Skip to content

Commit fe46f0e

Browse files
author
tempraturbo
committed
Add OnBeforeExecute and OnAfterExecute to the client
Both live in TRALClientHTTP.BeforeSendUrl, the single funnel every engine already goes through, so one implementation serves all six engines on Delphi and FPC without touching an engine unit. They report an attempt, not a call: the BaseURL failover and the 401 resend fire the pair again with Attempt one higher, which is the only way a handler can see the failover and time the right thing. OnBeforeExecute runs after the URL and its TLS policy are settled and before any network work, the token fetch included, and may refuse the attempt through ACancel/ACancelReason - failing it with the new rteCancelled, appended so every existing TRALTransportError value keeps its ordinal and CanSwitchURL's else already declines to resend it. OnAfterExecute always pairs with it, the refusal included, so a handler may count in one and discount in the other.
1 parent 34cc466 commit fe46f0e

7 files changed

Lines changed: 172 additions & 6 deletions

File tree

‎.agents/PROJECT_MAP.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,18 @@ Lazarus
253253
única — evento, senão pin, senão o veredito do próprio engine. Sem pin e sem evento
254254
nada muda, inclusive o fato de Indy e fpHTTP não validarem certificado por padrão.
255255
Ver `CLAUDE.md`, "Which server certificate a client accepts".
256+
7. `OnBeforeExecute` e `OnAfterExecute` também moram no `TRALClient` e também rodam
257+
dentro do `BeforeSendUrl`, então valem para os seis engines em Delphi e FPC sem
258+
tocar em unit de engine nenhuma. Eles dão à aplicação a palavra sobre **cada
259+
tentativa**: o primeiro roda depois da URL e da política de TLS dela decididas e
260+
antes de qualquer trabalho de rede (inclusive a busca de token), pode acrescentar
261+
header ou param no `ARequest` e pode recusar a tentativa com `ACancel` +
262+
`ACancelReason`, que falha com `rteCancelled`; o segundo roda quando a tentativa
263+
termina, seja como for que ela termine, e sempre pareado com o primeiro.
264+
`TRALExecInfo` leva URL, método, tentativa (1-based), engine, `Elapsed` em ms,
265+
`StatusCode`, `TransportError` e `ErrorMessage`. Tentativa não é chamada: o failover
266+
e o reenvio do 401 disparam o par de novo, com `Attempt` maior. Ver `CLAUDE.md`,
267+
"The application's own say over each attempt".
256268

257269
### 4.3) DBWare
258270

‎CLAUDE.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ Two engine traps live under this:
161161

162162
Verified with `testes_ral_matriz/timeout` (repro `tmout.dpr`, verifier `tmfix.dpr` + `fpc/tmfixfpc.lpr`), across Indy, mORMot2, netHTTP and fpHTTP.
163163

164+
### The application's own say over each attempt (`OnBeforeExecute`/`OnAfterExecute`)
165+
166+
`TRALClient.OnBeforeExecute` and `OnAfterExecute` live in `TRALClientHTTP.BeforeSendUrl` — the same single funnel as the resend above — so **one implementation serves every engine on both compilers**: no engine unit knows they exist, and `ExecuteThread`, `ExecuteSingle` and `TRALThreadClient` all pass through them.
167+
168+
They report an **attempt, not a call**, and that is deliberate: `BeforeSendUrl` rotates `BaseURL` on a transport failure and repeats once on a 401, so one `Post` can be three attempts. Each reports itself with `TRALExecInfo.Attempt` one higher; collapsing them would hide the failover and time the wrong thing. Whoever wants the call rather than the attempt ignores `Attempt > 1`. Fields the client cannot know yet come back empty, never invented — the same rule as `TRALCertInfo`.
169+
170+
`OnBeforeExecute` runs **after** the URL is settled and the TLS policy for it enforced, and **before any network work at all** — the `AutoGetToken` fetch included, since that one is a request of its own: whoever refuses for lack of connectivity should not pay for a token round trip first. `AInfo` is `const` on purpose (rewriting the URL there would slip past the pin decided just above), while `ARequest` is not — adding a header or a param is the point of the hook.
171+
172+
Setting `ACancel` fails the attempt with `TransportError = rteCancelled` and raises, instead of the application having to raise from inside the engine's stack. `rteCancelled` is **appended** to `TRALTransportError`, so every existing value keeps its ordinal and `CanSwitchURL`'s `else` already declines to resend it — nothing went out, so there is nothing to resend anywhere. `ACancelReason`, when given, *becomes* the message verbatim; left empty, RAL uses `emRequestCancelled` with the URL.
173+
174+
`OnAfterExecute` **always pairs with `OnBeforeExecute`** — including when the attempt raised, and including when the application itself refused it, which is why the refusal is raised from *inside* the `try` whose `finally` calls it. A handler may therefore count in one and discount in the other without ever losing a pair. `TRALExecInfo.ErrorMessage` comes from `ExceptObject`, not from `AResponse`: an exception that never reached `SetTransportError` would otherwise arrive indistinguishable from success. Both hooks run on the **calling** thread with no `Synchronize`, so under the default `ebMultiThread` they run on the `TRALThreadClient` and not on the main thread — reaching the UI from there is the handler's own business.
175+
176+
`CopyProperties` carries both, next to `SSL` and `OnValidateServerCert`: the DAO clones its client, and a clone that lost the hooks would stop reporting.
177+
164178
### A published `default` that disagrees with the constructor silently wins
165179

166180
`TRALClient.ConnectTimeout` declared `default 5000` while the constructor set 30000, and `RequestTimeout` declared `default 30000` while the constructor set 10000 — the two were swapped. The directive is not decoration: streaming skips writing a property whose value equals it, so typing exactly `5000` into the Object Inspector produced a `.dfm` with no `ConnectTimeout` at all and a component that ran with 30000. It never showed up in code-driven tests, where `default` has no effect whatsoever — only in the normal use, dropping the component on a form.

‎src/base/RALClient.pas‎

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
interface
44

55
uses
6-
Classes, SysUtils, SyncObjs,
6+
Classes, SysUtils, SyncObjs, DateUtils,
77
RALCustomObjects, RALTypes, RALAuthentication, RALRequest, RALResponse,
88
RALCompress, RALCripto, RALConsts, RALTools, RALToken, RALJSON, RALParams,
99
RALMimeTypes;
@@ -45,6 +45,62 @@ TRALCertInfo = record
4545
TRALOnValidateCert = function(ASender: TObject;
4646
const ACert: TRALCertInfo): boolean of object;
4747

48+
{ TRALExecInfo }
49+
50+
/// One ATTEMPT of one request - what the client is about to do, or has just
51+
/// done. An attempt is not a call: BeforeSendUrl rotates BaseURL on a
52+
/// transport failure and repeats once on a 401, and each pass reports itself
53+
/// with Attempt one higher. Collapsing them would hide the failover and
54+
/// report the wrong latency, so they come as they happen, and whoever wants
55+
/// the call instead of the attempt ignores Attempt > 1.
56+
/// Fields the client cannot know yet come back empty, never invented - the
57+
/// same rule as TRALCertInfo.
58+
TRALExecInfo = record
59+
/// the URL of THIS attempt, already after the BaseURL rotation
60+
URL: StringRAL;
61+
Method: TRALMethod;
62+
/// 1-based
63+
Attempt: IntegerRAL;
64+
/// which engine carried it, for an application that mixes engines
65+
Engine: StringRAL;
66+
/// milliseconds the attempt took - OnAfterExecute only, zero on Before
67+
Elapsed: Int64RAL;
68+
/// OnAfterExecute only; zero when no HTTP response happened
69+
StatusCode: IntegerRAL;
70+
/// OnAfterExecute only
71+
TransportError: TRALTransportError;
72+
/// OnAfterExecute only: the message of whatever ended the attempt, empty
73+
/// when nothing did. It is not AResponse.ResponseText: an exception that
74+
/// never reached SetTransportError would otherwise arrive indistinguishable
75+
/// from success
76+
ErrorMessage: StringRAL;
77+
end;
78+
79+
/// Called before each attempt goes out - after RAL settled the URL and
80+
/// enforced the TLS policy for it, and BEFORE any network work, the token
81+
/// fetch included, since that one is a request of its own.
82+
/// - set ACancel to refuse the attempt: RAL fails it with rteCancelled, the
83+
/// same way it fails a refused pin, instead of the application having to
84+
/// raise through the engine's stack
85+
/// - ACancelReason, when given, BECOMES the message, verbatim; left empty,
86+
/// RAL uses its own text with the URL. Only read when ACancel is True
87+
/// - AInfo is read-only on purpose: rewriting the URL here would slip past
88+
/// the pin and the TLS check decided just above. ARequest is not - adding a
89+
/// header or a param is the point of the hook
90+
TRALOnBeforeExecute = procedure(ASender: TObject; ARequest: TRALRequest;
91+
const AInfo: TRALExecInfo;
92+
var ACancel: boolean;
93+
var ACancelReason: StringRAL) of object;
94+
95+
/// Called when the attempt ends, whatever ended it - a response, a transport
96+
/// failure, an exception, or OnBeforeExecute refusing it. It ALWAYS pairs
97+
/// with OnBeforeExecute, so a handler may count in one and discount in the
98+
/// other. It runs on the calling thread, with no Synchronize: reaching the UI
99+
/// from here is the handler's own business.
100+
TRALOnAfterExecute = procedure(ASender: TObject; ARequest: TRALRequest;
101+
AResponse: TRALResponse;
102+
const AInfo: TRALExecInfo) of object;
103+
48104
/// What the ENGINE itself does about the server certificate - the pin and
49105
/// OnValidateServerCert are a separate question, and when either is set it is
50106
/// the one that decides
@@ -247,6 +303,8 @@ TRALClient = class(TRALComponent)
247303
FIndexUrl: IntegerRAL;
248304
FKeepAlive: boolean;
249305
FMaxRedirects: IntegerRAL;
306+
FOnAfterExecute: TRALOnAfterExecute;
307+
FOnBeforeExecute: TRALOnBeforeExecute;
250308
FOnResponse: TRALThreadClientResponse;
251309
FOnValidateServerCert: TRALOnValidateCert;
252310
FRequestTimeout: IntegerRAL;
@@ -353,6 +411,12 @@ TRALClient = class(TRALComponent)
353411
/// TLS options - see TRALClientSSL
354412
property SSL: TRALClientSSL read FSSL write SetSSL;
355413
property UserAgent: StringRAL read FUserAgent write SetUserAgent;
414+
/// Runs before each attempt leaves, and may refuse it - see TRALOnBeforeExecute
415+
property OnBeforeExecute: TRALOnBeforeExecute read FOnBeforeExecute
416+
write FOnBeforeExecute;
417+
/// Runs when each attempt ends, whatever ended it - see TRALOnAfterExecute
418+
property OnAfterExecute: TRALOnAfterExecute read FOnAfterExecute
419+
write FOnAfterExecute;
356420
property OnResponse: TRALThreadClientResponse read FOnResponse write FOnResponse;
357421
/// Judges the server certificate yourself. Assigned, it is the last word:
358422
/// it overrides both SSL.Pin and the engine's own verdict, and receives
@@ -374,6 +438,9 @@ TRALClient = class(TRALComponent)
374438
/// whatever the stack held. Default(T) would do it, and does not exist on
375439
/// the older compilers RAL still supports
376440
function RALEmptyCertInfo: TRALCertInfo;
441+
/// A TRALExecInfo with every field zeroed - what the client starts from, so
442+
/// no field ever reaches a handler carrying what was on the stack
443+
function RALEmptyExecInfo: TRALExecInfo;
377444
/// Splits "host", "host:port" or "[ipv6]:port" - the very format of the left
378445
/// side of an SSL.Pins line, published so that whatever writes that config
379446
/// can read it back the same way. An IPv6 without brackets is all host,
@@ -671,6 +738,11 @@ procedure TRALClient.CopyProperties(ADest: TRALClient);
671738
of what the pin was set for - and the DAO clones its client }
672739
ADest.SSL := Self.SSL;
673740
ADest.OnValidateServerCert := Self.OnValidateServerCert;
741+
742+
{ a clone that lost the hooks would stop reporting, and the DAO clones its
743+
client }
744+
ADest.OnBeforeExecute := Self.OnBeforeExecute;
745+
ADest.OnAfterExecute := Self.OnAfterExecute;
674746
end;
675747

676748
procedure TRALClient.SetAuthentication(AValue: TRALAuthClient);
@@ -1043,6 +1115,18 @@ function RALPinPlace(const ALine: StringRAL; out AHost: StringRAL;
10431115
RALSplitHostPort(Copy(ALine, 1, vIgual - 1), AHost, APort);
10441116
end;
10451117

1118+
function RALEmptyExecInfo: TRALExecInfo;
1119+
begin
1120+
Result.URL := '';
1121+
Result.Method := amGET;
1122+
Result.Attempt := 0;
1123+
Result.Engine := '';
1124+
Result.Elapsed := 0;
1125+
Result.StatusCode := 0;
1126+
Result.TransportError := rteNone;
1127+
Result.ErrorMessage := '';
1128+
end;
1129+
10461130
function RALEmptyCertInfo: TRALCertInfo;
10471131
begin
10481132
Result.Fingerprint := '';
@@ -1195,8 +1279,10 @@ procedure TRALClientHTTP.BeforeSendUrl(ARoute: StringRAL;
11951279
var
11961280
vConta, vMaxUrls, vResp, vErrorCode: IntegerRAL;
11971281
vParams: TStringList;
1198-
vURL: StringRAL;
1199-
vRepeat, vTriedToken: boolean;
1282+
vURL, vCancelReason: StringRAL;
1283+
vRepeat, vTriedToken, vCancel: boolean;
1284+
vInfo: TRALExecInfo;
1285+
vStart: TDateTime;
12001286
begin
12011287
vConta := 0;
12021288
vTriedToken := False;
@@ -1247,8 +1333,39 @@ procedure TRALClientHTTP.BeforeSendUrl(ARoute: StringRAL;
12471333
// freed pointer - or, when the token already existed and the block did not
12481334
// run at all, an uninitialised variable. Neither Basic nor JWT read this
12491335
// argument, but Digest and OAuth do.
1336+
{ The application's own say over THIS attempt. It runs here, with the URL
1337+
and the TLS policy for it already settled, and BEFORE any network work -
1338+
the token fetch below included, since that one is a request of its own:
1339+
whoever refuses for lack of connectivity should not pay for it. }
1340+
vCancel := False;
1341+
vCancelReason := '';
1342+
vStart := Now;
1343+
1344+
if Assigned(FParent.OnBeforeExecute) or Assigned(FParent.OnAfterExecute) then
1345+
begin
1346+
vInfo := RALEmptyExecInfo;
1347+
vInfo.URL := vURL;
1348+
vInfo.Method := AMethod;
1349+
vInfo.Attempt := vConta + 1;
1350+
vInfo.Engine := EngineName;
1351+
end;
1352+
1353+
if Assigned(FParent.OnBeforeExecute) then
1354+
FParent.OnBeforeExecute(FParent, ARequest, vInfo, vCancel, vCancelReason);
1355+
12501356
vParams := TStringList.Create;
12511357
try
1358+
{ the refusal lives INSIDE this try so that the OnAfterExecute in the
1359+
finally below covers it as well - a handler may count in one event and
1360+
discount in the other without ever losing a pair }
1361+
if vCancel then
1362+
begin
1363+
if vCancelReason = '' then
1364+
vCancelReason := StringRAL(Format(emRequestCancelled, [vURL]));
1365+
SetTransportError(AResponse, rteCancelled, 0, vCancelReason);
1366+
raise Exception.Create(string(vCancelReason));
1367+
end;
1368+
12521369
vParams.Sorted := True;
12531370
vParams.Add('method=' + RALMethodToHTTPMethod(AMethod));
12541371
vParams.Add('url=' + vURL);
@@ -1282,6 +1399,22 @@ procedure TRALClientHTTP.BeforeSendUrl(ARoute: StringRAL;
12821399
end;
12831400
finally
12841401
FreeAndNil(vParams);
1402+
1403+
{ Always paired with OnBeforeExecute - including when the attempt raised,
1404+
and including when the application refused it. ExceptObject is whatever
1405+
is unwinding right now, and it is the only way to name the failure here
1406+
without wrapping the whole attempt in one more try just to catch it and
1407+
re-raise. }
1408+
if Assigned(FParent.OnAfterExecute) then
1409+
begin
1410+
vInfo.Elapsed := MilliSecondsBetween(Now, vStart);
1411+
vInfo.StatusCode := AResponse.StatusCode;
1412+
vInfo.TransportError := AResponse.TransportError;
1413+
if ExceptObject is Exception then
1414+
vInfo.ErrorMessage := StringRAL(Exception(ExceptObject).Message);
1415+
1416+
FParent.OnAfterExecute(FParent, ARequest, AResponse, vInfo);
1417+
end;
12851418
end;
12861419

12871420
vConta := vConta + 1;

‎src/base/RALTypes.pas‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,13 @@ interface
9999
rteOther,
100100
/// the TLS handshake failed over the server certificate - refused by the
101101
/// engine's own validation, by SSL.Pin or by OnValidateServerCert
102-
/// - last on purpose: the four values above keep their ordinal, and
103-
/// CanSwitchURL's "else" already declines to resend what it does not know
104-
rteCertificate);
102+
rteCertificate,
103+
/// the application refused the attempt from OnBeforeExecute, so nothing
104+
/// went out and there is nothing to resend
105+
/// - new values are APPENDED, never inserted: every value above keeps its
106+
/// ordinal, and CanSwitchURL's "else" already declines to resend what it
107+
/// does not know
108+
rteCancelled);
105109

106110
{$IF Defined(FPC) or Defined(DELPHIXE3UP)}
107111
TRALBase64StringHelper = {$IFDEF FPC}type{$ELSE}record{$ENDIF} helper for StringRAL

‎src/languages/ralconsts_enus.inc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
emCertNotInspectable = 'The %s engine could not inspect the server certificate on this connection, so SSL.Pins and OnValidateServerCert cannot be applied. Use the Indy engine.';
1414
emCertPinInvalid = 'Every SSL.Pins line must end in the SHA-256 of the certificate: 64 hex digits, with or without separators.';
1515
emCertPinUnsupported = 'SSL.Pins is not supported by the %s engine on this platform: it cannot read the certificate fingerprint. Use the Indy engine, or decide in OnValidateServerCert.';
16+
emRequestCancelled = 'The request was refused by OnBeforeExecute: %s.';
1617
emCertRejected = 'Server certificate refused.';
1718
emCertRequiresTLS = 'SSL.Required is set, or a line of SSL.Pins applies to this host, so plain http is refused: %s.';
1819
emCompressInvalidFormat = 'Invalid compression Format.';

‎src/languages/ralconsts_eses.inc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
emCertNotInspectable = 'El motor %s no pudo inspeccionar el certificado del servidor en esta conexión, por lo que SSL.Pins y OnValidateServerCert no pueden aplicarse. Use el motor Indy.';
1414
emCertPinInvalid = 'Cada línea de SSL.Pins debe terminar en el SHA-256 del certificado: 64 dígitos hexadecimales, con o sin separadores.';
1515
emCertPinUnsupported = 'SSL.Pins no es compatible con el motor %s en esta plataforma: no puede leer la huella digital del certificado. Use el motor Indy o decida en OnValidateServerCert.';
16+
emRequestCancelled = 'La solicitud fue rechazada por OnBeforeExecute: %s.';
1617
emCertRejected = 'Certificado del servidor rechazado.';
1718
emCertRequiresTLS = 'SSL.Required está definido, o una línea de SSL.Pins aplica a este host, por lo que se rechaza http sin cifrar: %s.';
1819
emCompressInvalidFormat = 'Formato de compressión no és válido.';

‎src/languages/ralconsts_ptbr.inc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
emCertNotInspectable = 'O engine %s não conseguiu inspecionar o certificado do servidor nesta conexão, então SSL.Pins e OnValidateServerCert não podem ser aplicados. Use o engine Indy.';
1414
emCertPinInvalid = 'Toda linha de SSL.Pins deve terminar no SHA-256 do certificado: 64 dígitos hexadecimais, com ou sem separadores.';
1515
emCertPinUnsupported = 'SSL.Pins não é suportado pelo engine %s nesta plataforma: ele não consegue ler a impressão digital do certificado. Use o engine Indy ou decida no OnValidateServerCert.';
16+
emRequestCancelled = 'A requisição foi recusada pelo OnBeforeExecute: %s.';
1617
emCertRejected = 'Certificado do servidor recusado.';
1718
emCertRequiresTLS = 'SSL.Required está definido, ou uma linha de SSL.Pins vale para este host, então http puro é recusado: %s.';
1819
emCompressInvalidFormat = 'Formato de compressão inválido.';

0 commit comments

Comments
 (0)